local M = {}
local function read_disk(path, mode)
local f = io.open(path, mode or "r")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
local ID_PATTERN = "^[a-z0-9%-]+$"
local function is_array(t)
if type(t) ~= "table" then return false end
local i = 0
for _ in pairs(t) do
i = i + 1
if t[i] == nil then return false end
end
return true
end
local function list_toml_files(dir)
if not fs.exists(dir) then return {} end
local ok, entries = pcall(fs.list, dir)
if not ok then return {} end
local out = {}
for _, entry in ipairs(entries) do
local name = entry.name
if name and name:match("%.toml$") then
out[#out+1] = dir .. "/" .. name
end
end
table.sort(out)
return out
end
M.catalog = {}
local function validate_catalog_entry(decoded)
local errs = {}
local pkg = decoded and decoded.package
if type(pkg) ~= "table" then
errs[#errs+1] = { field = "package", message = "missing [package] table" }
return errs
end
if type(pkg.id) ~= "string" or not pkg.id:match(ID_PATTERN) then
errs[#errs+1] = { field = "package.id", message = "id must match [a-z0-9-]+" }
end
if type(pkg.display_name) ~= "string" or pkg.display_name == "" then
errs[#errs+1] = { field = "package.display_name", message = "required" }
end
if not is_array(pkg.methods) or #pkg.methods == 0 then
errs[#errs+1] = { field = "package.methods", message = "must be non-empty array" }
else
for _, m in ipairs(pkg.methods) do
if m ~= "apt" and m ~= "binary" then
errs[#errs+1] = { field = "package.methods", message = "unknown method: " .. tostring(m) }
elseif type(pkg[m]) ~= "table" then
errs[#errs+1] = { field = "package." .. m, message = "missing block for declared method" }
end
end
end
if type(pkg.apt) == "table" then
for _, f in ipairs({ "source_list", "package_name" }) do
if type(pkg.apt[f]) ~= "string" or pkg.apt[f] == "" then
errs[#errs+1] = { field = "package.apt." .. f, message = "required string" }
end
end
end
if type(pkg.binary) == "table" then
for _, f in ipairs({ "release_api", "asset_pattern", "sha256_source", "install_path", "mode" }) do
if type(pkg.binary[f]) ~= "string" or pkg.binary[f] == "" then
errs[#errs+1] = { field = "package.binary." .. f, message = "required string" }
end
end
if type(pkg.binary.asset_pattern) == "string" then
for ph in pkg.binary.asset_pattern:gmatch("{([^}]+)}") do
if ph ~= "arch" and ph ~= "uname_m" and ph ~= "tag" and ph ~= "ver" then
errs[#errs+1] = {
field = "package.binary.asset_pattern",
message = "unknown placeholder {" .. ph .. "} (allowed: arch, uname_m, tag, ver)",
}
end
end
end
end
return errs
end
function M.catalog.load(paths)
if type(paths) ~= "table" then
error("pkg.catalog.load: paths must be array of directory paths", 2)
end
local entries, errors = {}, {}
for layer_idx, dir in ipairs(paths) do
local files = list_toml_files(dir)
for _, file in ipairs(files) do
local raw = fs.read(file)
local ok, decoded = pcall(toml.parse, raw)
if not ok then
errors[#errors+1] = {
path = file, package_id = nil, field = nil,
message = "TOML parse error: " .. tostring(decoded),
}
else
local entry_errs = validate_catalog_entry(decoded)
if #entry_errs > 0 then
local id = (decoded.package and decoded.package.id) or file
for _, e in ipairs(entry_errs) do
errors[#errors+1] = {
path = file, package_id = id,
field = e.field, message = e.message,
}
end
if type(decoded.package) == "table" and type(decoded.package.id) == "string" then
entries[decoded.package.id] = nil
end
else
local entry = decoded.package
if layer_idx == 1 then
entry._origin = "built-in"
elseif layer_idx == 2 then
entry._origin = "plugin:" .. (dir:gsub("/+$", ""):match("([^/]+)$") or dir)
else
entry._origin = "operator:" .. (file:match("([^/]+)$") or file)
end
entries[entry.id] = entry
end
end
end
end
return { entries = entries, errors = errors }
end
function M.catalog.get(entries, id) return entries[id] end
function M.catalog.list(entries)
local arr = {}
for _, e in pairs(entries) do arr[#arr+1] = e end
table.sort(arr, function(a, b) return a.id < b.id end)
return arr
end
M.templates = {}
local ROOTFS_SOURCES = {
["machinectl-pull-tar"] = true, ["machinectl-pull-raw"] = true, ["machinectl-clone"] = true, ["debootstrap"] = true, }
local NSPAWN_RESOLV_CONF_VALUES = {
off = true, copy_host = true, copy_static = true, copy_uplink = true,
copy_stub = true, replace_host = true, replace_static = true,
replace_uplink = true, replace_stub = true, bind_host = true,
bind_static = true, bind_uplink = true, bind_stub = true,
delete = true, auto = true,
}
local function nspawn_resolv_conf_normalize(s)
if type(s) ~= "string" then return nil end
local norm = s:gsub("-", "_")
return NSPAWN_RESOLV_CONF_VALUES[norm] and norm or nil
end
local function validate_template_rootfs(rootfs, errs)
if type(rootfs) ~= "table" then
errs[#errs+1] = { field = "template.rootfs", message = "must be a table" }
return
end
local source = rootfs.source
if type(source) ~= "string" or not ROOTFS_SOURCES[source] then
errs[#errs+1] = {
field = "template.rootfs.source",
message = "must be one of: machinectl-pull-tar, machinectl-pull-raw, machinectl-clone, debootstrap",
}
return
end
if source == "machinectl-pull-tar" or source == "machinectl-pull-raw" then
if type(rootfs.url) ~= "string" or not rootfs.url:match("^https?://") then
errs[#errs+1] = {
field = "template.rootfs.url",
message = "required for " .. source .. "; must be http(s):// URL",
}
end
elseif source == "machinectl-clone" then
if type(rootfs.from) ~= "string" or rootfs.from == "" then
errs[#errs+1] = {
field = "template.rootfs.from",
message = "required for machinectl-clone (source machine name)",
}
end
elseif source == "debootstrap" then
if type(rootfs.suite) ~= "string" or not rootfs.suite:match("^[a-z][a-z0-9.%-]*$") then
errs[#errs+1] = { field = "template.rootfs.suite",
message = "required for debootstrap; must match ^[a-z][a-z0-9.-]*$" }
end
if type(rootfs.mirror) ~= "string" or not rootfs.mirror:match("^https?://") then
errs[#errs+1] = { field = "template.rootfs.mirror", message = "required for debootstrap; must be http(s):// URL" }
end
if rootfs.components ~= nil and type(rootfs.components) ~= "string" then
errs[#errs+1] = { field = "template.rootfs.components",
message = "must be string (comma-separated, e.g. main,universe)" }
elseif type(rootfs.components) == "string" and not rootfs.components:match("^[A-Za-z0-9,_%-]+$") then
errs[#errs+1] = { field = "template.rootfs.components",
message = "may only contain [A-Za-z0-9,_-]" }
end
if rootfs.keyring ~= nil and (type(rootfs.keyring) ~= "string" or not rootfs.keyring:match("^/")) then
errs[#errs+1] = { field = "template.rootfs.keyring",
message = "must be an absolute path to a keyring file" }
end
if rootfs.variant ~= nil and (type(rootfs.variant) ~= "string"
or not rootfs.variant:match("^[a-z%-]+$")) then
errs[#errs+1] = { field = "template.rootfs.variant",
message = "must be lowercase alphanumeric+dash (e.g. minbase, buildd, fakechroot)" }
end
if rootfs.include ~= nil and (type(rootfs.include) ~= "string"
or not rootfs.include:match("^[A-Za-z0-9.,_%-+]+$")) then
errs[#errs+1] = { field = "template.rootfs.include",
message = "must be comma-separated package names (e.g. systemd-sysv,dbus)" }
end
end
end
local function validate_template_nspawn(nspawn, errs)
if type(nspawn) ~= "table" then
errs[#errs+1] = { field = "template.nspawn", message = "must be a table" }
return
end
local function require_bool(field)
if nspawn[field] ~= nil and type(nspawn[field]) ~= "boolean" then
errs[#errs+1] = { field = "template.nspawn." .. field, message = "must be boolean" }
end
end
require_bool("boot")
require_bool("notify_ready")
require_bool("virtual_ethernet")
require_bool("private_users")
if nspawn.resolv_conf ~= nil and not nspawn_resolv_conf_normalize(nspawn.resolv_conf) then
errs[#errs+1] = {
field = "template.nspawn.resolv_conf",
message = "must be a systemd-nspawn --resolv-conf= value (e.g. bind-host, copy-host, off)",
}
end
if nspawn.binds ~= nil and not is_array(nspawn.binds) then
errs[#errs+1] = { field = "template.nspawn.binds", message = "must be array" }
end
if nspawn.binds_ro ~= nil and not is_array(nspawn.binds_ro) then
errs[#errs+1] = { field = "template.nspawn.binds_ro", message = "must be array" }
end
if nspawn.capabilities ~= nil and not is_array(nspawn.capabilities) then
errs[#errs+1] = { field = "template.nspawn.capabilities", message = "must be array" }
end
if nspawn.bridge ~= nil and (type(nspawn.bridge) ~= "string"
or not nspawn.bridge:match("^[A-Za-z0-9._%-]+$")) then
errs[#errs+1] = { field = "template.nspawn.bridge",
message = "must be a non-empty bridge name matching [A-Za-z0-9._-]+" }
end
end
local function validate_template_systemd(sd, errs)
if type(sd) ~= "table" then
errs[#errs+1] = { field = "template.systemd", message = "must be a table" }
return
end
if sd.enable ~= nil and not is_array(sd.enable) then
errs[#errs+1] = { field = "template.systemd.enable", message = "must be array of unit names" }
end
if sd.disable ~= nil and not is_array(sd.disable) then
errs[#errs+1] = { field = "template.systemd.disable", message = "must be array of unit names" }
end
end
local function validate_template_entry(decoded, catalog_entries)
local errs = {}
local t = decoded and decoded.template
if type(t) ~= "table" then
errs[#errs+1] = { field = "template", message = "missing [template] table" }
return errs
end
if type(t.id) ~= "string" or not t.id:match(ID_PATTERN) then
errs[#errs+1] = { field = "template.id", message = "id must match [a-z0-9-]+" }
end
if type(t.display_name) ~= "string" or t.display_name == "" then
errs[#errs+1] = { field = "template.display_name", message = "required" }
end
if not is_array(t.packages) then
errs[#errs+1] = { field = "template.packages", message = "must be array (may be empty)" }
else
for _, pkg_id in ipairs(t.packages) do
if type(pkg_id) ~= "string" or not pkg_id:match(ID_PATTERN) then
errs[#errs+1] = {
field = "template.packages",
message = "elements must be strings matching [a-z0-9-]+ (got " .. type(pkg_id) .. ")",
}
elseif type(catalog_entries) == "table" and catalog_entries[pkg_id] == nil then
errs[#errs+1] = {
field = "template.packages",
message = "references unknown catalog id: " .. tostring(pkg_id),
}
end
end
end
if t.rootfs ~= nil then validate_template_rootfs(t.rootfs, errs) end
if t.nspawn ~= nil then validate_template_nspawn(t.nspawn, errs) end
if t.systemd ~= nil then validate_template_systemd(t.systemd, errs) end
return errs
end
function M.templates.load(paths, catalog_entries)
if type(paths) ~= "table" then
error("pkg.templates.load: paths must be array of directory paths", 2)
end
local entries, errors = {}, {}
for layer_idx, dir in ipairs(paths) do
local files = list_toml_files(dir)
for _, file in ipairs(files) do
local raw = fs.read(file)
local ok, decoded = pcall(toml.parse, raw)
if not ok then
errors[#errors+1] = {
path = file, template_id = nil, field = nil,
message = "TOML parse error: " .. tostring(decoded),
}
else
local entry_errs = validate_template_entry(decoded, catalog_entries)
if #entry_errs > 0 then
local id = (decoded.template and decoded.template.id) or file
for _, e in ipairs(entry_errs) do
errors[#errors+1] = {
path = file, template_id = id,
field = e.field, message = e.message,
}
end
if type(decoded.template) == "table" and type(decoded.template.id) == "string" then
entries[decoded.template.id] = nil
end
else
local entry = decoded.template
if layer_idx == 1 then
entry._origin = "built-in"
elseif layer_idx == 2 then
entry._origin = "plugin:" .. (dir:gsub("/+$", ""):match("([^/]+)$") or dir)
else
entry._origin = "operator:" .. (file:match("([^/]+)$") or file)
end
entries[entry.id] = entry
end
end
end
end
return { entries = entries, errors = errors }
end
function M.templates.get(entries, id) return entries[id] end
function M.templates.list(entries)
local arr = {}
for _, e in pairs(entries) do arr[#arr+1] = e end
table.sort(arr, function(a, b) return a.id < b.id end)
return arr
end
M.target = {}
local is_root, sudo_prefix, shell_quote
local Target = {}
Target.__index = Target
function Target:exec(cmd, opts)
opts = opts or {}
if self.kind == "host" then
return shell.exec(cmd, opts)
elseif self.kind == "machine" then
if is_root() then
return systemd.machine_exec(self.id, cmd, opts)
else
local outer = ("sudo -n systemd-run --machine=%s --pipe --quiet --wait --collect /bin/sh -c %s"):format(
shell_quote(self.id), shell_quote(cmd))
return shell.exec(outer, opts)
end
else
error("unknown target kind: " .. tostring(self.kind))
end
end
function M.target.host()
return setmetatable({ kind = "host", id = "host" }, Target)
end
function M.target.machine(name)
if type(name) ~= "string" or name == "" then
error("pkg.target.machine: name required", 2)
end
if name == "host" then
error("pkg.target.machine: 'host' is reserved; use pkg.target.host()", 2)
end
if not name:match("^[A-Za-z0-9._%-]+$") then
error("pkg.target.machine: name must match [A-Za-z0-9._-]+ (got "
.. tostring(name) .. ")", 2)
end
return setmetatable({ kind = "machine", id = name }, Target)
end
M.version = {}
function M.version.parse(s)
if type(s) ~= "string" then return {0} end
local t = (s:gsub("^v", ""))
local out = {}
for piece in t:gmatch("[^%.]+") do
local n = tonumber(piece:match("^(%d+)"))
if n then out[#out+1] = n end
end
if #out == 0 then out[1] = 0 end
return out
end
function M.version.cmp(a, b)
local pa = M.version.parse(a)
local pb = M.version.parse(b)
local n = math.max(#pa, #pb)
for i = 1, n do
local ai = pa[i] or 0
local bi = pb[i] or 0
if ai < bi then return -1
elseif ai > bi then return 1 end
end
return 0
end
M.release = {}
function M.release.meta_path(entry_id, ctx)
if type(ctx) ~= "table" or type(ctx.cache_dir) ~= "string" then
error("pkg.release.meta_path: ctx.cache_dir required", 2)
end
return ctx.cache_dir .. "/" .. entry_id .. "/release_meta.json"
end
function M.release.read_meta(entry_id, ctx)
local p = M.release.meta_path(entry_id, ctx)
local raw = read_disk(p)
if not raw then return nil end
local ok, parsed = pcall(json.parse, raw)
if ok and type(parsed) == "table" then return parsed end
return nil
end
function M.release.refresh_meta(entry, ctx)
if not (entry and entry.binary and entry.binary.release_api) then
error("pkg.release.refresh_meta: entry.binary.release_api required", 2)
end
local headers = {
Accept = "application/vnd.github+json",
["User-Agent"] = "assay-pkg/" .. (entry.id or "unknown"),
}
local tok = env.get("GITHUB_TOKEN")
if type(tok) == "string" and tok ~= "" then
headers.Authorization = "Bearer " .. tok
end
local r = http.get(entry.binary.release_api, { headers = headers, timeout = 30 })
if r.status ~= 200 then
error("release_api fetch failed: HTTP " .. tostring(r.status))
end
local parsed = json.parse(r.body)
local meta = {
tag = parsed.tag_name,
ver = (parsed.tag_name or ""):gsub("^v", ""),
fetched_at = os.date("!%Y-%m-%dT%H:%M:%SZ"),
assets = parsed.assets or {},
}
fs.mkdir(ctx.cache_dir .. "/" .. entry.id)
fs.write(M.release.meta_path(entry.id, ctx), json.encode(meta))
return meta
end
function M.release.fetch_expected_sha256(b, meta, asset_name)
if b.sha256_source == "asset" then
local sha_url
for _, a in ipairs(meta.assets or {}) do
if a.name == asset_name .. ".sha256" then sha_url = a.browser_download_url end
end
if not sha_url then return nil end
local r = http.get(sha_url, {
timeout = 15,
headers = { ["User-Agent"] = "assay-pkg" },
})
if r.status ~= 200 then return nil end
return r.body:match("^(%x+)") or nil
elseif b.sha256_source == "checksums" then
local candidates = { "sha256sums.txt", "checksums.txt", "checksums" }
local sums_url
for _, name in ipairs(candidates) do
for _, a in ipairs(meta.assets or {}) do
if a.name == name then sums_url = a.browser_download_url; break end
end
if sums_url then break end
end
if not sums_url then return nil end
local r = http.get(sums_url, {
timeout = 15,
headers = { ["User-Agent"] = "assay-pkg" },
})
if r.status ~= 200 then return nil end
for line in r.body:gmatch("[^\n]+") do
local hex, name = line:match("^(%x+)%s+%*?(.+)$")
if name == asset_name then return hex end
end
return nil
end
return nil
end
M.method = { apt = {}, binary = {} }
local function noop_log(_) end
local function ctx_log(ctx) return (ctx and ctx.log) or noop_log end
local function safe_name(s)
if type(s) ~= "string" then return "" end
return (s:gsub("[^a-z0-9%-]", ""))
end
local _is_root_cached = nil
is_root = function()
if _is_root_cached == nil then
local r = shell.exec("id -u", {})
_is_root_cached = (r and r.stdout and r.stdout:match("^0") ~= nil) or false
end
return _is_root_cached
end
sudo_prefix = function()
return is_root() and "" or "sudo -n "
end
shell_quote = function(s)
if type(s) ~= "string" then return "''" end
return "'" .. s:gsub("'", [['"'"']]) .. "'"
end
function M.method.apt.query(target, entry)
if not (entry and entry.apt and entry.apt.package_name) then
return { installed = false }
end
local pkg_name = entry.apt.package_name
local cmd = ("dpkg-query -s %q 2>/dev/null"):format(pkg_name)
local r = target:exec(cmd, {})
if not r or r.status ~= 0 then return { installed = false } end
local status_line = (r.stdout or ""):match("\nStatus:%s+([^\n]+)")
or (r.stdout or ""):match("^Status:%s+([^\n]+)")
local ver_line = (r.stdout or ""):match("\nVersion:%s+([^\n]+)")
or (r.stdout or ""):match("^Version:%s+([^\n]+)")
local installed = status_line and status_line:match("install ok installed") ~= nil
local row = {
installed = installed,
version = installed and ver_line or nil,
}
if not installed then return row end
if row.installed then
local pol = target:exec(("apt-cache policy %q 2>/dev/null"):format(pkg_name), {})
if pol and pol.status == 0 and pol.stdout then
local candidate = pol.stdout:match("Candidate:%s*(%S+)")
if candidate and candidate ~= "(none)" then
row.available = candidate
row.upgradable = M.version.cmp(row.version, candidate) < 0
end
end
end
return row
end
local function apt_add_source_via_sudo(entry, log)
local b = entry.apt
local id = safe_name(entry.id)
local sudo = sudo_prefix()
local list_dst = "/etc/apt/sources.list.d/" .. id .. ".list"
local key_dst = "/usr/share/keyrings/" .. id .. ".gpg"
local key_tmp = "/tmp/assay-pkg-key-" .. id .. ".gpg"
http.download(b.key_url, key_tmp, { timeout = 30 })
local changed = false
local want_key = read_disk(key_tmp, "rb")
local cur_key = read_disk(key_dst, "rb")
if cur_key ~= want_key then
local r = shell.exec(
sudo .. ("install -D -m 0644 -o root -g root %q %q"):format(key_tmp, key_dst), {})
if not r or r.status ~= 0 then
fs.remove(key_tmp)
error(("install %s -> %s failed: %s"):format(key_tmp, key_dst, (r and r.stderr) or "unknown"))
end
changed = true
end
fs.remove(key_tmp)
local want_list = b.source_list .. "\n"
local cur_list = read_disk(list_dst, "rb")
if cur_list ~= want_list then
local list_tmp = "/tmp/assay-pkg-list-" .. id .. ".list"
fs.write(list_tmp, want_list)
local r = shell.exec(
sudo .. ("install -D -m 0644 -o root -g root %q %q"):format(list_tmp, list_dst), {})
fs.remove(list_tmp)
if not r or r.status ~= 0 then
error(("install list -> %s failed: %s"):format(list_dst, (r and r.stderr) or "unknown"))
end
changed = true
end
log(" source: " .. (changed and "wrote" or "unchanged"))
return changed
end
local function apt_add_source_in_machine(target, entry, log)
local b = entry.apt
local id = safe_name(entry.id)
local list_dst = "/etc/apt/sources.list.d/" .. id .. ".list"
local key_dst = "/usr/share/keyrings/" .. id .. ".gpg"
local key_tmp = "/tmp/assay-pkg-key-" .. id .. ".gpg"
http.download(b.key_url, key_tmp, { timeout = 30 })
local b64r = shell.exec(("base64 -w 0 %q"):format(key_tmp), {})
fs.remove(key_tmp)
if not b64r or b64r.status ~= 0 then
error("base64 encode failed for key: " .. ((b64r and b64r.stderr) or "unknown"))
end
local b64 = (b64r.stdout or ""):gsub("%s+$", "")
local cmd_key = ("echo %s | base64 -d | install -D -m 0644 /dev/stdin %s"):format(
shell_quote(b64), shell_quote(key_dst))
local r1 = target:exec(cmd_key, { timeout = 60 })
if not r1 or r1.status ~= 0 then
error("install key in " .. target.id .. " failed: " .. ((r1 and r1.stderr) or "unknown"))
end
local list_content = b.source_list .. "\n"
local cmd_list = ("printf '%%s' %s | install -D -m 0644 /dev/stdin %s"):format(
shell_quote(list_content), shell_quote(list_dst))
local r2 = target:exec(cmd_list, { timeout = 60 })
if not r2 or r2.status ~= 0 then
error("install list in " .. target.id .. " failed: " .. ((r2 and r2.stderr) or "unknown"))
end
local r3 = target:exec("apt-get update -qq", { timeout = 300 })
if not r3 or r3.status ~= 0 then
error("apt-get update in " .. target.id .. " failed: " .. ((r3 and r3.stderr) or "unknown"))
end
log(" source: installed in " .. target.id)
end
function M.method.apt.install(target, entry, ctx)
if not (entry and entry.apt) then error("apt block missing on entry " .. tostring(entry and entry.id)) end
local log = ctx_log(ctx)
local b = entry.apt
if target.kind == "host" and b.source_list and b.key_url then
local changed = apt_add_source_via_sudo(entry, log)
if changed then
local up = shell.exec(sudo_prefix() .. "apt-get update", { timeout = 300 })
if not up or up.status ~= 0 then
error("apt-get update failed: " .. ((up and up.stderr) or "unknown"))
end
end
elseif target.kind == "machine" and b.source_list and b.key_url then
apt_add_source_in_machine(target, entry, log)
end
local extra = (ctx and ctx.op == "upgrade") and " --only-upgrade" or ""
if target.kind == "host" then
local cmd = sudo_prefix() ..
("env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends%s -- %q"):format(
extra, b.package_name)
local r = shell.exec(cmd, { timeout = 600 })
if not r or r.status ~= 0 then
error("apt-get install failed: " .. ((r and r.stderr) or "unknown"))
end
else
local cmd = ("DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends%s -- %q"):format(
extra, b.package_name)
local r = target:exec(cmd, { timeout = 600 })
if not r or r.status ~= 0 then
error("apt-get install in " .. tostring(target.id) .. " failed: " .. ((r and r.stderr) or "unknown"))
end
end
end
function M.method.apt.remove(target, entry, ctx)
if not (entry and entry.apt) then error("apt block missing on entry " .. tostring(entry and entry.id)) end
local log = ctx_log(ctx)
local b = entry.apt
local q = M.method.apt.query(target, entry)
if not q.installed then
log(" not installed; nothing to remove")
return
end
if target.kind == "host" then
local cmd = sudo_prefix() ..
("env DEBIAN_FRONTEND=noninteractive apt-get remove -y -- %q"):format(b.package_name)
local r = shell.exec(cmd, { timeout = 300 })
if not r or r.status ~= 0 then
error("apt-get remove failed: " .. ((r and r.stderr) or "unknown"))
end
else
local cmd = ("DEBIAN_FRONTEND=noninteractive apt-get remove -y -- %q"):format(b.package_name)
local r = target:exec(cmd, { timeout = 300 })
if not r or r.status ~= 0 then
error("apt-get remove in " .. tostring(target.id) .. " failed: " .. ((r and r.stderr) or "unknown"))
end
end
end
local function arch_for(target)
local r = target:exec("uname -m", {})
local uname_m = (r and r.stdout or "x86_64"):gsub("%s+$", "")
local arch = (uname_m == "x86_64" and "amd64") or
(uname_m == "aarch64" and "arm64") or uname_m
return arch, uname_m
end
function M.method.binary.query(target, entry)
if not (entry and entry.binary) then return { installed = false } end
local b = entry.binary
local function probe_path(p)
if not p or p == "" then return nil end
local r1 = target:exec(("test -x %q"):format(p), {})
if not r1 or r1.status ~= 0 then return nil end
local r2 = target:exec(("%q --version 2>&1 || true"):format(p), {})
local ver
if r2 and r2.stdout then
ver = r2.stdout:match("(%d+%.[%d%.]+)") or r2.stdout:match("(%d+%.%d+)")
end
return { version = ver, path = p }
end
local hit = probe_path(b.install_path)
if hit then
return { installed = true, version = hit.version, installed_at = hit.path }
end
local cmd_name = safe_name(b.command_name or entry.id)
if cmd_name == "" then return { installed = false } end
local r = target:exec(("command -v %q 2>/dev/null"):format(cmd_name), {})
if r and r.status == 0 and r.stdout then
local found = (r.stdout:gsub("%s+$", ""))
if found ~= "" then
local hit2 = probe_path(found)
if hit2 then
return { installed = true, version = hit2.version, installed_at = hit2.path }
end
end
end
return { installed = false }
end
local function marker_path_for(ctx, entry, target)
if target.kind == "host" then
return ctx.cache_dir .. "/" .. entry.id .. "/installed.json"
else
return ctx.cache_dir .. "/" .. entry.id .. "/installed." .. target.id .. ".json"
end
end
function M.method.binary.install(target, entry, ctx)
if not (entry and entry.binary) then error("binary block missing on entry " .. tostring(entry and entry.id)) end
if not (ctx and ctx.cache_dir) then error("ctx.cache_dir required for binary install") end
local log = ctx_log(ctx)
local b = entry.binary
local installed_meta_path = marker_path_for(ctx, entry, target)
local arch, uname_m = arch_for(target)
local meta = M.release.refresh_meta(entry, ctx)
local tag = meta.tag
local ver = (tag or ""):gsub("^v", "")
local asset_name = b.asset_pattern
:gsub("{arch}", arch)
:gsub("{uname_m}", uname_m)
:gsub("{tag}", tag)
:gsub("{ver}", ver)
local asset_url
for _, a in ipairs(meta.assets or {}) do
if a.name == asset_name then asset_url = a.browser_download_url; break end
end
if not asset_url then
error("no asset matching pattern: " .. asset_name)
end
do
local raw = read_disk(installed_meta_path)
if raw then
local ok, m = pcall(json.parse, raw)
if ok and m and m.version == ver then
local present
if target.kind == "host" then
present = fs.exists(b.install_path)
else
local r = target:exec(("test -x %q"):format(b.install_path), {})
present = r and r.status == 0
end
if present then
log(" no-op: already at " .. ver)
return { skipped = true, reason = "already at " .. ver, noop = true }
end
end
end
end
fs.mkdir(ctx.cache_dir .. "/" .. entry.id)
local asset_path = ctx.cache_dir .. "/" .. entry.id .. "/" .. asset_name
http.download(asset_url, asset_path, { timeout = 300 })
log(" downloaded " .. asset_name)
local expected_sha = M.release.fetch_expected_sha256(b, meta, asset_name)
if not expected_sha then
error("sha256 not available for " .. asset_name)
end
local actual_sha = crypto.hash_file(asset_path, "sha256")
if actual_sha ~= expected_sha then
error(("sha256 mismatch: expected %s got %s"):format(expected_sha, actual_sha))
end
log(" sha256 ok")
local source_path
if b.archive_member then
local extracted = ctx.cache_dir .. "/" .. entry.id .. "/" .. ver .. ".bin"
local member = b.archive_member
:gsub("{arch}", arch)
:gsub("{uname_m}", uname_m)
:gsub("{tag}", tag)
:gsub("{ver}", ver)
compress.untar(asset_path, extracted, { member = member })
source_path = extracted
else
source_path = asset_path
end
if target.kind == "host" then
local sudo = sudo_prefix()
local owner_args = is_root() and "" or "-o root -g root "
local cmd = sudo ..
("install -D -m %s %s%q %q"):format(b.mode, owner_args, source_path, b.install_path)
local r = shell.exec(cmd, {})
if not r or r.status ~= 0 then
error(("install %s -> %s failed: %s"):format(source_path, b.install_path,
(r and r.stderr) or "unknown"))
end
else
local sudo = is_root() and "" or "sudo -n "
local install_dir = b.install_path:match("^(.*)/[^/]+$") or "/"
local mkdir_r = target:exec(("mkdir -p %q"):format(install_dir), { timeout = 30 })
if not mkdir_r or mkdir_r.status ~= 0 then
error(("mkdir -p %s in %s failed: %s"):format(
install_dir, target.id, (mkdir_r and mkdir_r.stderr) or "unknown"))
end
local copy_cmd = ("%smachinectl copy-to %s %s %s"):format(
sudo, shell_quote(target.id), shell_quote(source_path), shell_quote(b.install_path))
local r = shell.exec(copy_cmd, { timeout = 300 })
if not r or r.status ~= 0 then
local stderr = (r and r.stderr) or "unknown"
if not stderr:lower():find("file exists", 1, true) then
error(("machinectl copy-to %s -> %s in %s failed: %s"):format(
source_path, b.install_path, target.id, stderr))
end
end
local chmod_r = target:exec(("chmod %s %q"):format(b.mode, b.install_path), { timeout = 30 })
if not chmod_r or chmod_r.status ~= 0 then
error(("chmod %s on %s in %s failed: %s"):format(
b.mode, b.install_path, target.id, (chmod_r and chmod_r.stderr) or "unknown"))
end
end
log(" installed at " .. b.install_path .. " (mode " .. b.mode .. ")")
local installed_sha
if target.kind == "host" then
installed_sha = crypto.hash_file(b.install_path, "sha256")
else
installed_sha = crypto.hash_file(source_path, "sha256")
end
local installed_doc = {
version = ver,
sha256 = installed_sha,
asset_sha256 = actual_sha,
method = "binary",
installed_at = os.date("!%Y-%m-%dT%H:%M:%SZ"),
from_url = asset_url,
}
fs.write(installed_meta_path, json.encode(installed_doc))
end
function M.method.binary.remove(target, entry, ctx)
if not (entry and entry.binary) then error("binary block missing on entry " .. tostring(entry and entry.id)) end
if not (ctx and ctx.cache_dir) then error("ctx.cache_dir required for binary remove") end
local log = ctx_log(ctx)
local installed_meta_path = marker_path_for(ctx, entry, target)
local present
if target.kind == "host" then
present = fs.exists(entry.binary.install_path)
else
local r = target:exec(("test -x %q"):format(entry.binary.install_path), {})
present = r and r.status == 0
end
if not present then
log(" not installed; nothing to remove")
return
end
local marker_raw = read_disk(installed_meta_path)
if marker_raw then
local ok, m = pcall(json.parse, marker_raw)
if not ok then
error("refusing to remove: marker file " .. installed_meta_path ..
" is corrupt (json parse failed: " .. tostring(m) .. ")")
end
if type(m) == "table" and type(m.sha256) == "string" then
local actual
if target.kind == "host" then
actual = crypto.hash_file(entry.binary.install_path, "sha256")
else
local r = target:exec(("sha256sum %q"):format(entry.binary.install_path), {})
if not r or r.status ~= 0 then
error("refusing to remove: sha256sum in " .. target.id .. " failed: "
.. ((r and r.stderr) or "unknown"))
end
actual = (r.stdout or ""):match("^(%x+)")
end
if actual ~= m.sha256 then
error("refusing to remove: binary at " .. entry.binary.install_path ..
" was modified outside our control (sha mismatch)")
end
end
end
if target.kind == "host" then
local r = shell.exec(sudo_prefix() .. ("rm -f %q"):format(entry.binary.install_path), {})
if not r or r.status ~= 0 then
error(("rm %s failed: %s"):format(entry.binary.install_path, (r and r.stderr) or "unknown"))
end
else
local r = target:exec(("rm -f %q"):format(entry.binary.install_path), { timeout = 30 })
if not r or r.status ~= 0 then
error(("rm %s in %s failed: %s"):format(entry.binary.install_path, target.id,
(r and r.stderr) or "unknown"))
end
end
if fs.exists(installed_meta_path) then fs.remove(installed_meta_path) end
log(" removed " .. entry.binary.install_path)
end
local function method_for(entry)
if not entry or type(entry.methods) ~= "table" then return nil, nil end
local name = entry.methods[1]
return M.method[name], name
end
function M.query(target, entry, ctx)
local handler, method_name = method_for(entry)
if not handler then return { installed = false } end
local row = handler.query(target, entry)
if ctx and ctx.cache_dir and row.installed and method_name == "binary" then
local meta = M.release.read_meta(entry.id, ctx)
if meta then
row.available = meta.ver or meta.tag
row.upgradable = (row.version
and row.available
and M.version.cmp(row.version, row.available) < 0) or false
end
local marker = marker_path_for(ctx, entry, target)
if not fs.exists(marker) then
row.upgradable = true
row.unmanaged = true
end
end
return row
end
function M.query_all(target, catalog_entries, ctx)
local out = {}
for id, entry in pairs(catalog_entries) do
out[id] = M.query(target, entry, ctx)
end
return out
end
function M.apply(plan, target, catalog_entries, ctx)
ctx = ctx or {}
local on_progress = ctx.on_progress or function(_,_,_,_) end
local result = { ok = {}, skipped = {}, failed = {} }
for i, op in ipairs(plan) do
local entry = catalog_entries[op.id]
local handler = method_for(entry)
local fn
if handler then
if op.op == "install" or op.op == "upgrade" then fn = handler.install
elseif op.op == "remove" then fn = handler.remove end
end
on_progress(i, op, "start")
if not fn then
local err = "unsupported (op=" .. tostring(op.op) ..
", method=" .. tostring(op.method) .. ")"
result.failed[#result.failed+1] = { op = op, error = err }
on_progress(i, op, "failed", err)
else
local op_ctx = setmetatable({ op = op.op }, { __index = ctx })
local ok, ret = pcall(fn, target, entry, op_ctx)
if not ok then
result.failed[#result.failed+1] = { op = op, error = tostring(ret or "unknown") }
on_progress(i, op, "failed", tostring(ret or "unknown"))
elseif type(ret) == "table" and ret.skipped then
result.skipped[#result.skipped+1] = {
op = op, reason = ret.reason or "skipped", noop = ret.noop or false,
}
on_progress(i, op, "skipped", ret.reason or "skipped")
else
result.ok[#result.ok+1] = op
on_progress(i, op, "ok")
end
end
end
return result
end
function M.plan(target_id, desired_set, actual, catalog_entries)
if type(desired_set) ~= "table" then
error("pkg.plan: desired_set must be array", 2)
end
actual = actual or {}
local sorted = {}
for _, id in ipairs(desired_set) do sorted[#sorted+1] = id end
table.sort(sorted)
local plan = {}
for _, id in ipairs(sorted) do
local entry = catalog_entries[id]
if not entry then
plan[#plan+1] = {
op = "skip", id = id, method = nil,
reason = "no catalog entry for id",
}
else
local act = actual[id] or { installed = false }
local method = entry.methods[1] if not act.installed then
plan[#plan+1] = {
op = "install", id = id, method = method,
target_version = act.available,
}
elseif act.upgradable
or (act.available and act.version
and M.version.cmp(act.version, act.available) < 0) then
plan[#plan+1] = {
op = "upgrade", id = id, method = method,
from = act.version, to = act.available,
}
end
end
end
return plan
end
return M