local M = {}
local _is_root_cached = nil
local function is_root()
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
local function sudo_prefix()
return is_root() and "" or "sudo -n "
end
local function shell_quote(s)
if type(s) ~= "string" then return "''" end
return "'" .. s:gsub("'", [['"'"']]) .. "'"
end
local function validate_name(name)
if type(name) ~= "string" or name == "" then
error("nspawn: name must be a non-empty string", 2)
end
if not name:match("^[A-Za-z0-9._%-]+$") then
error("nspawn: name must match [A-Za-z0-9._-]+ (got " .. tostring(name) .. ")", 2)
end
if name:sub(1, 1) == "-" then
error("nspawn: name must not start with '-'", 2)
end
end
function M.list()
return systemd.list_machines()
end
function M.get(name)
validate_name(name)
local ok, info = pcall(systemd.machine_status, name)
if ok then return info end
return nil
end
function M.exists(name)
validate_name(name)
if M.get(name) ~= nil then return true end
return fs.exists("/var/lib/machines/" .. name)
end
M.config = {}
local NSPAWN_DIR = "/etc/systemd/nspawn"
local function unit_path(name)
return NSPAWN_DIR .. "/" .. name .. ".nspawn"
end
function M.config.read(name)
validate_name(name)
local p = unit_path(name)
if not fs.exists(p) then return nil end
return fs.read(p)
end
local function ini_repeat(key, values)
if type(values) ~= "table" then return "" end
local out = {}
for _, v in ipairs(values) do
out[#out+1] = key .. "=" .. tostring(v)
end
return table.concat(out, "\n") .. (#out > 0 and "\n" or "")
end
local function bool_to_yesno(v)
if v == true then return "yes" end
if v == false then return "no" end
return nil
end
local function nspawn_resolv_conf_emit(s)
if type(s) ~= "string" then return nil end
return (s:gsub("_", "-"))
end
function M.config.render(cfg)
if type(cfg) ~= "table" then
error("nspawn.config.render: cfg must be a table", 2)
end
local parts = {}
local exec_lines = {}
local b = bool_to_yesno(cfg.boot)
if b then exec_lines[#exec_lines+1] = "Boot=" .. b end
b = bool_to_yesno(cfg.notify_ready)
if b then exec_lines[#exec_lines+1] = "NotifyReady=" .. b end
b = bool_to_yesno(cfg.private_users)
if b then exec_lines[#exec_lines+1] = "PrivateUsers=" .. b end
if cfg.capabilities and type(cfg.capabilities) == "table" then
exec_lines[#exec_lines+1] = "Capability=" .. table.concat(cfg.capabilities, ",")
end
if cfg.resolv_conf then
local rc = nspawn_resolv_conf_emit(cfg.resolv_conf)
if rc then exec_lines[#exec_lines+1] = "ResolvConf=" .. rc end
end
if #exec_lines > 0 then
parts[#parts+1] = "[Exec]\n" .. table.concat(exec_lines, "\n") .. "\n"
end
local files_lines = {}
if cfg.binds then
files_lines[#files_lines+1] = ini_repeat("Bind", cfg.binds):sub(1, -2)
end
if cfg.binds_ro then
files_lines[#files_lines+1] = ini_repeat("BindReadOnly", cfg.binds_ro):sub(1, -2)
end
if cfg.inaccessible then
files_lines[#files_lines+1] = ini_repeat("Inaccessible", cfg.inaccessible):sub(1, -2)
end
local filtered = {}
for _, s in ipairs(files_lines) do
if s and s ~= "" then filtered[#filtered+1] = s end
end
if #filtered > 0 then
parts[#parts+1] = "[Files]\n" .. table.concat(filtered, "\n") .. "\n"
end
local net_lines = {}
b = bool_to_yesno(cfg.virtual_ethernet)
if b then net_lines[#net_lines+1] = "VirtualEthernet=" .. b end
if type(cfg.bridge) == "string" and cfg.bridge ~= "" then
net_lines[#net_lines+1] = "Bridge=" .. cfg.bridge
end
if #net_lines > 0 then
parts[#parts+1] = "[Network]\n" .. table.concat(net_lines, "\n") .. "\n"
end
return table.concat(parts, "\n")
end
function M.config.write(name, cfg)
validate_name(name)
local body = M.config.render(cfg)
local dst = unit_path(name)
local tmp = "/tmp/assay-nspawn-" .. name .. "." .. tostring(os.time()) .. ".nspawn"
fs.write(tmp, body)
local cmd = sudo_prefix() ..
("install -D -m 0644 -o root -g root %s %s"):format(shell_quote(tmp), shell_quote(dst))
local r = shell.exec(cmd, {})
fs.remove(tmp)
if not r or r.status ~= 0 then
error("nspawn.config.write: install failed: " .. ((r and r.stderr) or "unknown"))
end
return { ok = true, path = dst, bytes = #body }
end
function M.start(name)
validate_name(name)
local r = systemd.unit_action("systemd-nspawn@" .. name .. ".service", "start", { timeout = 60 })
if not r or r.status ~= 0 then
error("nspawn.start: " .. name .. ": " .. ((r and r.stderr) or "unknown"))
end
return { ok = true }
end
function M.stop(name, opts)
validate_name(name)
opts = opts or {}
local action = opts.force and "stop" or "stop" local r = systemd.unit_action("systemd-nspawn@" .. name .. ".service", action,
{ timeout = opts.timeout or 60 })
if not r or r.status ~= 0 then
if r and r.stderr and r.stderr:find("not loaded", 1, true) then
return { ok = true, was_running = false }
end
error("nspawn.stop: " .. name .. ": " .. ((r and r.stderr) or "unknown"))
end
return { ok = true }
end
function M.wait_ready(name, opts)
validate_name(name)
opts = opts or {}
local timeout = opts.timeout or 60
local poll_ms = opts.poll_ms or 500
local deadline = os.time() + timeout
while os.time() < deadline do
local info = M.get(name)
if info and type(info.leader_pid) == "number" and info.leader_pid > 0 then
return { ok = true, info = info }
end
if sleep then sleep(poll_ms / 1000.0)
else shell.exec("sleep " .. tostring(poll_ms / 1000.0), {}) end
end
error("nspawn.wait_ready: " .. name .. " did not register with leader_pid within " ..
tostring(timeout) .. "s")
end
function M.provision(spec)
if type(spec) ~= "table" then
error("nspawn.provision: spec table required", 2)
end
validate_name(spec.name)
if M.exists(spec.name) then
error("nspawn.provision: '" .. spec.name .. "' already exists; destroy it first", 2)
end
local rootfs = spec.rootfs or {}
local cfg = spec.config or {}
local on_stage = spec.on_stage or function(_,_,_) end
on_stage("rootfs", "in_progress")
if rootfs.source == "machinectl-pull-tar" then
if type(rootfs.url) ~= "string" then
error("nspawn.provision: rootfs.url required for pull-tar", 2)
end
local r = machinectl.pull_tar(rootfs.url, spec.name, {
verify = spec.verify_image or false,
timeout = rootfs.timeout or 1800,
})
if not r or r.status ~= 0 then
error("nspawn.provision: pull-tar failed: " .. ((r and r.stderr) or "unknown"))
end
elseif rootfs.source == "machinectl-pull-raw" then
if type(rootfs.url) ~= "string" then
error("nspawn.provision: rootfs.url required for pull-raw", 2)
end
local r = machinectl.pull_raw(rootfs.url, spec.name, {
verify = spec.verify_image or false,
timeout = rootfs.timeout or 1800,
})
if not r or r.status ~= 0 then
error("nspawn.provision: pull-raw failed: " .. ((r and r.stderr) or "unknown"))
end
elseif rootfs.source == "machinectl-clone" then
if type(rootfs.from) ~= "string" then
error("nspawn.provision: rootfs.from required for clone", 2)
end
local r = machinectl.clone(rootfs.from, spec.name, { timeout = rootfs.timeout or 600 })
if not r or r.status ~= 0 then
error("nspawn.provision: clone failed: " .. ((r and r.stderr) or "unknown"))
end
elseif rootfs.source == "debootstrap" then
if type(rootfs.suite) ~= "string" or type(rootfs.mirror) ~= "string" then
error("nspawn.provision: debootstrap requires suite + mirror", 2)
end
local target = "/var/lib/machines/" .. spec.name
local flags = { "--variant=" .. (rootfs.variant or "minbase") }
if type(rootfs.components) == "string" and rootfs.components ~= "" then
flags[#flags+1] = "--components=" .. rootfs.components
end
if type(rootfs.keyring) == "string" and rootfs.keyring ~= "" then
flags[#flags+1] = "--keyring=" .. shell_quote(rootfs.keyring)
end
if type(rootfs.include) == "string" and rootfs.include ~= "" then
flags[#flags+1] = "--include=" .. rootfs.include
end
local cmd = sudo_prefix() ..
("debootstrap %s %s %s %s"):format(
table.concat(flags, " "),
shell_quote(rootfs.suite),
shell_quote(target),
shell_quote(rootfs.mirror))
local r = shell.exec(cmd, { timeout = rootfs.timeout or 1800 })
if not r or r.status ~= 0 then
error("nspawn.provision: debootstrap failed: " .. ((r and r.stderr) or "unknown"))
end
else
on_stage("rootfs", "failed", "unknown rootfs.source: " .. tostring(rootfs.source))
error("nspawn.provision: unknown rootfs.source: " .. tostring(rootfs.source), 2)
end
on_stage("rootfs", "done")
if spec.systemd and type(spec.systemd.enable) == "table"
and #spec.systemd.enable > 0 then
local rootfs_path = "/var/lib/machines/" .. spec.name
for _, svc in ipairs(spec.systemd.enable) do
if type(svc) ~= "string" or not svc:match("^[A-Za-z0-9._%-:@\\]+$") then
error("nspawn.provision: invalid systemd unit name: " .. tostring(svc), 2)
end
local cmd = sudo_prefix() ..
("chroot %s systemctl enable %s"):format(
shell_quote(rootfs_path), shell_quote(svc))
local r = shell.exec(cmd, { timeout = 30 })
if not r or r.status ~= 0 then
if not (r and r.stderr and r.stderr:find("already enabled", 1, true)) then
error("nspawn.provision: chroot enable " .. svc ..
" failed: " .. ((r and r.stderr) or "unknown"))
end
end
end
end
on_stage("unit", "in_progress")
M.config.write(spec.name, cfg)
on_stage("unit", "done")
on_stage("boot", "in_progress")
local en = systemd.unit_action("systemd-nspawn@" .. spec.name .. ".service", "enable",
{ timeout = 30 })
if en and en.status ~= 0 and not (en.stderr or ""):find("already", 1, true) then
error("nspawn.provision: enable failed: " .. (en.stderr or "unknown"))
end
M.start(spec.name)
local ready = M.wait_ready(spec.name, { timeout = spec.ready_timeout or 60 })
on_stage("boot", "done")
return { ok = true, name = spec.name, info = ready.info }
end
function M.destroy(name, opts)
validate_name(name)
opts = opts or {}
if M.get(name) ~= nil then
pcall(M.stop, name, { timeout = 30 })
local deadline = os.time() + 15
while os.time() < deadline and M.get(name) ~= nil do
if sleep then sleep(0.25) else shell.exec("sleep 0.25", {}) end
end
end
local _ = systemd.unit_action("systemd-nspawn@" .. name .. ".service", "disable",
{ timeout = 30 })
local unit = unit_path(name)
if fs.exists(unit) then
local r = shell.exec(sudo_prefix() .. ("rm -f %s"):format(shell_quote(unit)), {})
if not r or r.status ~= 0 then
error("nspawn.destroy: rm " .. unit .. " failed: " .. ((r and r.stderr) or "unknown"))
end
end
if fs.exists("/var/lib/machines/" .. name) then
local r = machinectl.remove(name, { timeout = 60 })
if not r or r.status ~= 0 then
error("nspawn.destroy: machinectl remove failed: " .. ((r and r.stderr) or "unknown"))
end
end
return { ok = true }
end
return M