467 lines
13 KiB
Luau
467 lines
13 KiB
Luau
--!nonstrict
|
|
-- Docker backend for Mini Docker. All other entries communicate with this
|
|
-- singleton through noctalia.state, keeping subprocess ownership in one place.
|
|
|
|
local snapshot = {
|
|
available = false,
|
|
loading = true,
|
|
busy = false,
|
|
containers = {},
|
|
images = {},
|
|
volumes = {},
|
|
networks = {},
|
|
runningCount = 0,
|
|
error = "",
|
|
updatedAt = 0,
|
|
revision = 0,
|
|
}
|
|
|
|
local refreshGeneration = 0
|
|
local refreshPending = false
|
|
local refreshAgain = false
|
|
local actionBusy = false
|
|
local dataSignature = ""
|
|
|
|
local function trim(value)
|
|
return noctalia.string.trim(tostring(value or ""))
|
|
end
|
|
|
|
local function shellQuote(value)
|
|
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function shellCommand(args)
|
|
local quoted = {}
|
|
for _, value in ipairs(args) do
|
|
table.insert(quoted, shellQuote(value))
|
|
end
|
|
return table.concat(quoted, " ")
|
|
end
|
|
|
|
local function runDocker(args, callback, timeoutMs)
|
|
local command = { "docker" }
|
|
for _, value in ipairs(args) do
|
|
table.insert(command, value)
|
|
end
|
|
return noctalia.runAsync(shellCommand(command), callback, timeoutMs or 30000)
|
|
end
|
|
|
|
local function decodeLines(output, mapper)
|
|
local rows = {}
|
|
for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do
|
|
line = trim(line)
|
|
if line ~= "" then
|
|
local decoded, err = noctalia.json.decode(line)
|
|
if type(decoded) == "table" then
|
|
table.insert(rows, mapper(decoded))
|
|
else
|
|
noctalia.log(`mini-docker: ignored malformed Docker JSON: {err or "unknown error"}`)
|
|
end
|
|
end
|
|
end
|
|
return rows
|
|
end
|
|
|
|
local function parseContainers(output)
|
|
return decodeLines(output, function(item)
|
|
return {
|
|
id = tostring(item.ID or ""),
|
|
name = tostring(item.Names or ""),
|
|
image = tostring(item.Image or ""),
|
|
state = tostring(item.State or "unknown"),
|
|
status = tostring(item.Status or ""),
|
|
ports = tostring(item.Ports or ""),
|
|
created = tostring(item.CreatedAt or ""),
|
|
}
|
|
end)
|
|
end
|
|
|
|
local function parseImages(output)
|
|
return decodeLines(output, function(item)
|
|
local repository = tostring(item.Repository or "<none>")
|
|
local tag = tostring(item.Tag or "latest")
|
|
return {
|
|
repository = repository,
|
|
tag = tag,
|
|
name = repository .. ":" .. tag,
|
|
id = tostring(item.ID or ""),
|
|
size = tostring(item.Size or ""),
|
|
created = tostring(item.CreatedAt or ""),
|
|
isRunning = false,
|
|
}
|
|
end)
|
|
end
|
|
|
|
local function parseVolumes(output)
|
|
return decodeLines(output, function(item)
|
|
return {
|
|
name = tostring(item.Name or ""),
|
|
driver = tostring(item.Driver or ""),
|
|
mountpoint = tostring(item.Mountpoint or ""),
|
|
}
|
|
end)
|
|
end
|
|
|
|
local function parseNetworks(output)
|
|
return decodeLines(output, function(item)
|
|
local name = tostring(item.Name or "")
|
|
return {
|
|
name = name,
|
|
id = tostring(item.ID or ""),
|
|
driver = tostring(item.Driver or ""),
|
|
scope = tostring(item.Scope or ""),
|
|
isDefault = name == "bridge" or name == "host" or name == "none",
|
|
}
|
|
end)
|
|
end
|
|
|
|
local function markRunningImages(images, containers)
|
|
for _, image in ipairs(images) do
|
|
for _, container in ipairs(containers) do
|
|
if container.image == image.name
|
|
or container.image == image.repository
|
|
or container.image == image.id
|
|
or image.id:sub(1, 12) == container.image then
|
|
-- Docker refuses to remove an image referenced by stopped containers too.
|
|
image.isRunning = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function publishSnapshot()
|
|
snapshot.busy = actionBusy
|
|
noctalia.state.set("docker_snapshot", snapshot)
|
|
end
|
|
|
|
local function updateRevision(signature)
|
|
if signature ~= dataSignature then
|
|
dataSignature = signature
|
|
snapshot.revision += 1
|
|
end
|
|
end
|
|
|
|
local function refreshIntervalMs()
|
|
local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 5
|
|
seconds = math.max(1, math.min(30, math.floor(seconds)))
|
|
return seconds * 1000
|
|
end
|
|
|
|
local refreshAll
|
|
|
|
refreshAll = function()
|
|
if refreshPending then
|
|
refreshAgain = true
|
|
return
|
|
end
|
|
|
|
refreshPending = true
|
|
refreshAgain = false
|
|
refreshGeneration += 1
|
|
local generation = refreshGeneration
|
|
|
|
if not noctalia.commandExists("docker") then
|
|
snapshot.available = false
|
|
snapshot.loading = false
|
|
snapshot.error = noctalia.tr("result.docker_missing")
|
|
snapshot.containers = {}
|
|
snapshot.images = {}
|
|
snapshot.volumes = {}
|
|
snapshot.networks = {}
|
|
snapshot.runningCount = 0
|
|
refreshPending = false
|
|
updateRevision("docker-missing:" .. snapshot.error)
|
|
publishSnapshot()
|
|
return
|
|
end
|
|
|
|
snapshot.loading = true
|
|
publishSnapshot()
|
|
|
|
local signatureParts = {}
|
|
local containers = {}
|
|
local images = {}
|
|
local volumes = {}
|
|
local networks = {}
|
|
|
|
local function recordResult(kind, result)
|
|
signatureParts[kind] = table.concat({
|
|
tostring(result and result.exitCode or -1),
|
|
tostring(result and result.stdout or ""),
|
|
tostring(result and result.stderr or ""),
|
|
}, "\0")
|
|
end
|
|
|
|
local function finish(containerResult)
|
|
if generation ~= refreshGeneration then
|
|
return
|
|
end
|
|
if containerResult == nil or containerResult.exitCode ~= 0 then
|
|
snapshot.available = false
|
|
snapshot.error = trim(containerResult and containerResult.stderr)
|
|
if snapshot.error == "" then
|
|
snapshot.error = noctalia.tr("result.docker_unreachable")
|
|
end
|
|
snapshot.containers = {}
|
|
snapshot.images = {}
|
|
snapshot.volumes = {}
|
|
snapshot.networks = {}
|
|
snapshot.runningCount = 0
|
|
else
|
|
local runningCount = 0
|
|
for _, container in ipairs(containers) do
|
|
if container.state == "running" then
|
|
runningCount += 1
|
|
end
|
|
end
|
|
markRunningImages(images, containers)
|
|
snapshot.available = true
|
|
snapshot.error = ""
|
|
snapshot.containers = containers
|
|
snapshot.images = images
|
|
snapshot.volumes = volumes
|
|
snapshot.networks = networks
|
|
snapshot.runningCount = runningCount
|
|
snapshot.updatedAt = os.time()
|
|
end
|
|
|
|
snapshot.loading = false
|
|
refreshPending = false
|
|
updateRevision(table.concat({
|
|
signatureParts.containers or "",
|
|
signatureParts.images or "",
|
|
signatureParts.volumes or "",
|
|
signatureParts.networks or "",
|
|
}, "\1"))
|
|
publishSnapshot()
|
|
if refreshAgain then
|
|
refreshAll()
|
|
end
|
|
end
|
|
|
|
local function launch(kind, args, callback)
|
|
local started = runDocker(args, function(result)
|
|
if generation ~= refreshGeneration then return end
|
|
recordResult(kind, result)
|
|
callback(result)
|
|
end)
|
|
if not started then
|
|
local result = { exitCode = -1, stdout = "", stderr = "could not start Docker", timedOut = false }
|
|
recordResult(kind, result)
|
|
callback(result)
|
|
end
|
|
end
|
|
|
|
launch("containers", { "ps", "-a", "--format", "{{json .}}" }, function(containerResult)
|
|
if containerResult.exitCode ~= 0 then
|
|
finish(containerResult)
|
|
return
|
|
end
|
|
containers = parseContainers(containerResult.stdout)
|
|
launch("images", { "images", "--format", "{{json .}}" }, function(imageResult)
|
|
if imageResult.exitCode == 0 then
|
|
images = parseImages(imageResult.stdout)
|
|
end
|
|
launch("volumes", { "volume", "ls", "--format", "{{json .}}" }, function(volumeResult)
|
|
if volumeResult.exitCode == 0 then
|
|
volumes = parseVolumes(volumeResult.stdout)
|
|
end
|
|
launch("networks", { "network", "ls", "--format", "{{json .}}" }, function(networkResult)
|
|
if networkResult.exitCode == 0 then
|
|
networks = parseNetworks(networkResult.stdout)
|
|
end
|
|
finish(containerResult)
|
|
end)
|
|
end)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
local function actionResult(command, ok, message, extra)
|
|
local result = {
|
|
requestId = command.requestId,
|
|
action = command.action,
|
|
ok = ok,
|
|
message = message or "",
|
|
}
|
|
if type(extra) == "table" then
|
|
for key, value in pairs(extra) do
|
|
result[key] = value
|
|
end
|
|
end
|
|
noctalia.state.set("docker_action_result", result)
|
|
end
|
|
|
|
local function finishAction(command, result)
|
|
actionBusy = false
|
|
local ok = result ~= nil and result.exitCode == 0 and not result.timedOut
|
|
local message = trim(ok and result.stdout or (result and result.stderr))
|
|
if message == "" then
|
|
message = ok and noctalia.tr("result.success") or noctalia.tr("result.failed", { error = "unknown error" })
|
|
end
|
|
actionResult(command, ok, message)
|
|
if ok then
|
|
noctalia.notify(noctalia.tr("title"), message)
|
|
else
|
|
noctalia.notifyError(noctalia.tr("title"), message)
|
|
end
|
|
publishSnapshot()
|
|
refreshAll()
|
|
end
|
|
|
|
local function validContainerName(name)
|
|
return name == "" or name:match("^[%w][%w_.-]*$") ~= nil
|
|
end
|
|
|
|
local function validPort(port)
|
|
local number = tonumber(port)
|
|
return number ~= nil and number >= 1 and number <= 65535 and math.floor(number) == number
|
|
end
|
|
|
|
local function inspectImage(command)
|
|
if actionBusy then
|
|
actionResult(command, false, noctalia.tr("result.command_busy"))
|
|
return
|
|
end
|
|
actionBusy = true
|
|
publishSnapshot()
|
|
local started = runDocker({ "image", "inspect", "--format", "{{json .Config.ExposedPorts}}", tostring(command.image or "") }, function(result)
|
|
actionBusy = false
|
|
local port = nil
|
|
if result and result.exitCode == 0 then
|
|
local decoded = noctalia.json.decode(trim(result.stdout))
|
|
if type(decoded) == "table" then
|
|
for key in pairs(decoded) do
|
|
port = tostring(key):match("^(%d+)/")
|
|
if port ~= nil then
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
actionResult(command, result ~= nil and result.exitCode == 0, trim(result and result.stderr), { exposedPort = port })
|
|
publishSnapshot()
|
|
end)
|
|
if not started then
|
|
actionBusy = false
|
|
actionResult(command, false, "could not start Docker")
|
|
publishSnapshot()
|
|
end
|
|
end
|
|
|
|
local function executeAction(command)
|
|
if type(command) ~= "table" or type(command.action) ~= "string" then
|
|
return
|
|
end
|
|
if command.action == "refresh" then
|
|
refreshAll()
|
|
return
|
|
end
|
|
if command.action == "inspect_image" then
|
|
inspectImage(command)
|
|
return
|
|
end
|
|
if actionBusy then
|
|
actionResult(command, false, noctalia.tr("result.command_busy"))
|
|
return
|
|
end
|
|
|
|
local args = nil
|
|
if command.action == "start_container" then
|
|
args = { "start", tostring(command.id or "") }
|
|
elseif command.action == "stop_container" then
|
|
args = { "stop", tostring(command.id or "") }
|
|
elseif command.action == "restart_container" then
|
|
args = { "restart", tostring(command.id or "") }
|
|
elseif command.action == "remove_container" then
|
|
args = { "rm", tostring(command.id or "") }
|
|
elseif command.action == "remove_image" then
|
|
args = { "rmi", tostring(command.id or "") }
|
|
elseif command.action == "remove_volume" then
|
|
args = { "volume", "rm", tostring(command.name or "") }
|
|
elseif command.action == "remove_network" then
|
|
local name = tostring(command.name or "")
|
|
if name == "bridge" or name == "host" or name == "none" then
|
|
actionResult(command, false, noctalia.tr("panel.default_network"))
|
|
return
|
|
end
|
|
args = { "network", "rm", tostring(command.id or "") }
|
|
elseif command.action == "run_image" then
|
|
local image = trim(command.image)
|
|
local name = trim(command.name)
|
|
local network = trim(command.network)
|
|
local port = trim(command.port)
|
|
if image == "" then
|
|
actionResult(command, false, noctalia.tr("result.failed", { error = "missing image" }))
|
|
return
|
|
end
|
|
if not validContainerName(name) then
|
|
actionResult(command, false, noctalia.tr("run_form.invalid_name"))
|
|
return
|
|
end
|
|
if port ~= "" and not validPort(port) then
|
|
actionResult(command, false, noctalia.tr("run_form.invalid_port"))
|
|
return
|
|
end
|
|
args = { "run", "-d" }
|
|
if name ~= "" then
|
|
table.insert(args, "--name")
|
|
table.insert(args, name)
|
|
end
|
|
if type(command.environment) == "table" then
|
|
for _, env in ipairs(command.environment) do
|
|
local key = tostring(env):match("^([^=]+)=") or ""
|
|
if key:match("^[A-Za-z_][A-Za-z0-9_]*$") == nil then
|
|
actionResult(command, false, noctalia.tr("run_form.invalid_environment", { line = tostring(env) }))
|
|
return
|
|
end
|
|
table.insert(args, "-e")
|
|
table.insert(args, tostring(env))
|
|
end
|
|
end
|
|
if port ~= "" then
|
|
table.insert(args, "-p")
|
|
table.insert(args, port .. ":" .. port)
|
|
end
|
|
if network ~= "" and network ~= "bridge" then
|
|
table.insert(args, "--network")
|
|
table.insert(args, network)
|
|
end
|
|
table.insert(args, image)
|
|
end
|
|
|
|
if args == nil then
|
|
actionResult(command, false, `Unknown Docker action: {command.action}`)
|
|
return
|
|
end
|
|
|
|
actionBusy = true
|
|
publishSnapshot()
|
|
local launched = runDocker(args, function(result) finishAction(command, result) end, 60000)
|
|
if not launched then
|
|
actionBusy = false
|
|
actionResult(command, false, noctalia.tr("result.failed", { error = "could not start Docker" }))
|
|
publishSnapshot()
|
|
end
|
|
end
|
|
|
|
noctalia.state.watch("docker_command", executeAction)
|
|
noctalia.setUpdateInterval(refreshIntervalMs())
|
|
refreshAll()
|
|
|
|
function update()
|
|
refreshAll()
|
|
end
|
|
|
|
function onConfigChanged()
|
|
noctalia.setUpdateInterval(refreshIntervalMs())
|
|
refreshAll()
|
|
end
|
|
|
|
function onIpc(event, _payload)
|
|
if event == "refresh" then
|
|
refreshAll()
|
|
end
|
|
end
|