SysDeck/bridge/builder.py

2156 lines
83 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
SysDeck - Image Builder Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Surfaces the system image-builder backend(s) actually installed on this
host and returns a unified JSON interface so the Builder panel can list
available build profiles / image specs without knowing which tool the
operator picked.
Target distros: Arch Linux and Debian (the decision that shapes
every backend choice below):
- Arch Linux → mkosi (systemd's own image builder; pacman -S mkosi)
↘ archiso (Arch Live ISO builder; pacman -S archiso)
- Debian → vmdb2 (Debian project's image builder; apt install vmdb2)
↘ live-build (Debian Live ISO builder; apt install live-build)
Fedora/RHEL (osbuild-composer / composer-cli) is out of scope:
osbuild-composer is not packaged for Arch or Debian. mkosi and vmdb2
are the canonical equivalents and are invoked as
separate processes via subprocess — the suite (MIT) and the image
builders remain independent programs. No builder code is bundled.
The detected builder(s) determine which profile/spec discovery
functions are used. Multiple backends may coexist on a host (e.g. an
Arch box that has both mkosi and archiso installed); the panel surfaces
each backend's profiles grouped under that backend.
Usage:
python3 /usr/lib/sysdeck/bridge/builder.py status
python3 /usr/lib/sysdeck/bridge/builder.py profiles
python3 /usr/lib/sysdeck/bridge/builder.py summary
python3 /usr/lib/sysdeck/bridge/builder.py backends
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
# ── Backend discovery ────────────────────────────────────────────────
#
# Each candidate backend is a tuple of (backend_id, binary_name,
# version_args, kind). We probe via shutil.which() — that respects PATH
# and works uniformly on Arch/Debian without invoking the binary. The
# os-release–based DISTRO constant from bridge/__init__.py is used only
# as a hint for the install_cmd hint strings returned to the UI; the
# which() check is authoritative (a user may have mkosi installed on
# Debian, or vmdb2 installed on Arch — both work fine).
#
# Order matters for the panel's "primary backend" choice: mkosi is
# preferred on Arch, vmdb2 is preferred on Debian. If both are present,
# the one matching the host's distro wins as primary.
CANDIDATES = [
# backend_id, binary, version-args, kind, distro_hint
("mkosi", "mkosi", ["--version"], "image", "arch"),
("vmdb2", "vmdb2", ["--version"], "image", "debian"),
("archiso", "mkarchiso", ["--version"], "iso", "arch"),
("live-build", "lb", ["--version"], "iso", "debian"),
]
def _detect_backends() -> list[dict[str, Any]]:
"""Return the list of installed image-builder backends.
Each entry: {id, binary, version, kind, path}. `version` is the
trimmed first line of `<binary> --version` output, or '' if the
binary rejected the flag (some tools print to stderr). `path` is
the absolute path resolved by shutil.which().
"""
found: list[dict[str, Any]] = []
for backend_id, binary, vargs, kind, _hint in CANDIDATES:
resolved = shutil.which(binary)
if not resolved:
continue
version = _try_version(resolved, vargs)
found.append({
"id": backend_id,
"binary": binary,
"path": resolved,
"version": version,
"kind": kind,
})
return found
def _try_version(path: str, args: list[str]) -> str:
"""Run `<path> <args>` and return the trimmed first line of stdout
or stderr. Returns '' on any failure (binary missing flag,
non-zero exit, etc.) — never raises."""
try:
r = subprocess.run(
[path, *args],
capture_output=True, text=True, check=False, timeout=5,
)
out = (r.stdout or r.stderr or "").strip()
# First non-empty line — version strings like "mkosi 22" or
# "vmdb2 0.1.0" tend to be the first line.
for line in out.splitlines():
line = line.strip()
if line:
return line
return ""
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return ""
BACKENDS: list[dict[str, Any]] = _detect_backends()
def _primary_backend() -> dict[str, Any] | None:
"""Pick the primary backend for this host.
Preference order:
1. Backend whose distro_hint matches the host distro (mkosi on
Arch, vmdb2 on Debian).
2. First installed backend of kind='image' (a real image builder
beats an ISO-only tool like archiso / live-build).
3. First installed backend of any kind.
4. None.
"""
if not BACKENDS:
return None
# Match host distro against each candidate's hint.
try:
# Local import to avoid a hard dependency on the bridge package
# being importable (builder.py is invoked as a standalone script).
sys.path.insert(0, str(Path(__file__).resolve().parent))
from __init__ import DISTRO # type: ignore[import-not-found]
except Exception: # noqa: BLE001 — fallback to 'unknown'
DISTRO = "unknown"
for hint in ("arch", "debian"):
if DISTRO == hint:
for b in BACKENDS:
# Find candidate's hint
for cid, _bin, _vargs, _kind, chint in CANDIDATES:
if cid == b["id"] and chint == hint:
return b
for b in BACKENDS:
if b["kind"] == "image":
return b
return BACKENDS[0]
# ── Profile / spec discovery ────────────────────────────────────────
#
# Each backend stores its build profiles in well-known locations:
#
# mkosi:
# - /etc/mkosi/mkosi.conf (single profile)
# - /etc/mkosi/mkosi.conf.d/*.conf (drop-in fragments)
# - /etc/mkosi/mkosi.profiles/*.profile (named profiles, mkosi v22+)
# - any directory containing a `mkosi.conf` (project-local)
# Walked dirs: /etc/mkosi/, /usr/share/mkosi/, ~/.config/mkosi/
#
# archiso:
# - /usr/share/archiso/configs/<name>/ (shipped profiles: baseline, releng)
# - /etc/archiso/configs/<name>/ (operator-defined overrides)
#
# vmdb2:
# - /etc/vmdb2/*.yaml / *.yml (system specs)
# - /usr/share/vmdb2/specs/*.yaml (shipped examples)
# - ~/.config/vmdb2/*.yaml (per-user specs)
#
# live-build:
# - any directory containing a `config/` subdir built by `lb config`
# - Walked dirs: /var/cache/live-build/, /usr/share/live-build/,
# /etc/live-build/, ~/.config/live-build/
MKOSI_DIRS = [
# Per-profile directories under /etc/mkosi/profiles/<name>/ are the
# primary location. Each profile carries a real `mkosi.conf` — the
# only filename mkosi reads automatically from the cwd. Drop-in
# fragments under /etc/mkosi/mkosi.conf.d/ are ignored unless a
# parent /etc/mkosi/mkosi.conf exists to layer them onto, so
# profiles never rely on fragments.
Path("/etc/mkosi/profiles"),
Path("/etc/mkosi"),
Path("/usr/share/mkosi"),
Path.home() / ".config" / "mkosi",
]
ARCHISO_DIRS = [
Path("/usr/share/archiso/configs"),
Path("/etc/archiso/configs"),
]
VMDB2_DIRS = [
Path("/etc/vmdb2"),
Path("/usr/share/vmdb2/specs"),
Path.home() / ".config" / "vmdb2",
]
LIVE_BUILD_DIRS = [
Path("/etc/live-build"),
Path("/usr/share/live-build"),
Path.home() / ".config" / "live-build",
]
# v0.0.48: destination roots for profile-copy. Module-level (not
# hardcoded inside profile_copy) so unit tests can patch them with
# tempdirs instead of touching real /etc/ paths. Mirrors the
# ARCHISO_DIRS / LIVE_BUILD_DIRS pattern above.
ARCHISO_COPY_DEST = Path("/etc/archiso/configs")
LIVE_BUILD_COPY_DEST = Path("/etc/live-build")
def _mkosi_profiles() -> list[dict[str, str]]:
"""List mkosi profiles / config roots on this host.
Each entry: {name, path, type}. `type` is 'mkosi.conf' for a
directory containing mkosi.conf (project-local), 'profile' for a
named .profile file, or 'fragment' for a .conf drop-in fragment.
"""
profiles: list[dict[str, str]] = []
seen: set[str] = set()
for d in MKOSI_DIRS:
if not d.is_dir():
continue
# mkosi.conf at the root of a config dir — single-profile case.
root_conf = d / "mkosi.conf"
if root_conf.is_file():
key = str(root_conf)
if key not in seen:
profiles.append({
"name": d.name,
"path": str(root_conf),
"type": "mkosi.conf",
})
seen.add(key)
# Named profiles (mkosi v22+): mkosi.profiles/<name>.profile
profiles_dir = d / "mkosi.profiles"
if profiles_dir.is_dir():
for p in sorted(profiles_dir.glob("*.profile")):
key = str(p)
if key in seen:
continue
profiles.append({
"name": p.stem,
"path": str(p),
"type": "profile",
})
seen.add(key)
# Drop-in fragments: mkosi.conf.d/*.conf
confd = d / "mkosi.conf.d"
if confd.is_dir():
for p in sorted(confd.glob("*.conf")):
key = str(p)
if key in seen:
continue
profiles.append({
"name": p.stem,
"path": str(p),
"type": "fragment",
})
seen.add(key)
return profiles
def _archiso_profiles() -> list[dict[str, str]]:
"""List archiso profile directories.
Each entry: {name, path, type}. `path` is the profile directory
(containing profiledef.sh + airootfs/)."""
profiles: list[dict[str, str]] = []
seen: set[str] = set()
for d in ARCHISO_DIRS:
if not d.is_dir():
continue
for child in sorted(d.iterdir()):
if not child.is_dir():
continue
# An archiso profile dir must contain profiledef.sh.
if not (child / "profiledef.sh").is_file():
continue
key = str(child)
if key in seen:
continue
profiles.append({
"name": child.name,
"path": str(child),
"type": "archiso-profile",
})
seen.add(key)
return profiles
def _vmdb2_profiles() -> list[dict[str, str]]:
"""List vmdb2 spec YAML files.
Each entry: {name, path, type}. `name` is the file stem."""
profiles: list[dict[str, str]] = []
seen: set[str] = set()
for d in VMDB2_DIRS:
if not d.is_dir():
continue
for pattern in ("*.yaml", "*.yml"):
for p in sorted(d.glob(pattern)):
key = str(p)
if key in seen:
continue
profiles.append({
"name": p.stem,
"path": str(p),
"type": "vmdb2-spec",
})
seen.add(key)
return profiles
def _live_build_profiles() -> list[dict[str, str]]:
"""List live-build config directories.
Each entry: {name, path, type}. `path` is a directory that contains
a `config/` subdir (the marker that `lb config` was run there).
"""
profiles: list[dict[str, str]] = []
seen: set[str] = set()
for d in LIVE_BUILD_DIRS:
if not d.is_dir():
continue
for child in sorted(d.iterdir()):
if not child.is_dir():
continue
if not (child / "config").is_dir():
continue
key = str(child)
if key in seen:
continue
profiles.append({
"name": child.name,
"path": str(child),
"type": "live-build-config",
})
seen.add(key)
return profiles
# ── Per-backend dispatch ────────────────────────────────────────────
#
# Each backend's profile-discovery function is registered under its
# backend_id. The summary command iterates over BACKENDS and calls the
# matching discovery function. Unknown / missing backends contribute
# an empty list.
DISCOVERY = {
"mkosi": _mkosi_profiles,
"archiso": _archiso_profiles,
"vmdb2": _vmdb2_profiles,
"live-build": _live_build_profiles,
}
# ── Command surface ─────────────────────────────────────────────────
def status() -> dict[str, Any]:
"""Return the operational status of the image-builder stack.
Returns:
{
"state": "active" | "inactive" | "unavailable",
"primary": {id, binary, version, ...} | None,
"backends": [<backend dict>, ...],
"distro": "arch" | "debian" | ...
}
`state` is 'active' when at least one backend is installed; the
JS panel treats 'active' as "show the profile list" and otherwise
shows an install hint with the recommended package for this distro.
"""
primary = _primary_backend()
if BACKENDS:
state = "active"
else:
state = "unavailable"
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from __init__ import DISTRO # type: ignore[import-not-found]
except Exception: # noqa: BLE001
DISTRO = "unknown"
return {
"state": state,
"primary": primary,
"backends": BACKENDS,
"distro": DISTRO,
}
def profiles() -> list[dict[str, Any]]:
"""Return a flat list of all build profiles/specs across all
installed backends. Each entry includes a `backend` field naming
the tool that owns it, so the JS can group them under that
backend's header in the panel."""
out: list[dict[str, Any]] = []
for b in BACKENDS:
discover = DISCOVERY.get(b["id"])
if not discover:
continue
for p in discover():
p["backend"] = b["id"]
out.append(p)
return out
def summary() -> dict[str, Any]:
"""Combined status + profiles in one call — what the panel renders."""
s = status()
profs = profiles()
s["profiles"] = profs
s["profileCount"] = len(profs)
return s
def backends() -> list[dict[str, Any]]:
"""Return just the installed backend list — useful for the panel's
'install hint' UI when no backend is present."""
return BACKENDS
def install_hint() -> dict[str, str]:
"""Return the recommended install command for the host distro.
Used by the panel when `status.state == 'unavailable'` so the
operator sees the exact pacman/apt command to run instead of a
generic 'install mkosi' message."""
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from __init__ import DISTRO # type: ignore[import-not-found]
except Exception: # noqa: BLE001
DISTRO = "unknown"
hints = {
"arch": {
"primary": "mkosi",
"primary_cmd": "sudo pacman -S --needed mkosi",
"iso": "archiso",
"iso_cmd": "sudo pacman -S --needed archiso",
},
"debian": {
"primary": "vmdb2",
"primary_cmd": "sudo apt install -y vmdb2",
"iso": "live-build",
"iso_cmd": "sudo apt install -y live-build",
},
}
return hints.get(DISTRO, {
"primary": "mkosi",
"primary_cmd": "install mkosi (or vmdb2 on Debian)",
"iso": "archiso / live-build",
"iso_cmd": "install archiso (Arch) or live-build (Debian)",
})
# ── Build operations ──────────────────────────────────────────────
#
# Build images, create and delete profiles, list build artifacts,
# and tail build logs.
#
# Build state lives under /var/lib/sysdeck/builder/:
# state/<build-id>.json per-build state record
# artifacts/<profile>/ produced image/ISO files (backend puts them here)
# logs/<build-id>.log full stdout+stderr of the build
#
# Build IDs are timestamps: <profile>-<YYYYMMDDHHMMSS>. The JS panel
# can refresh build-status() periodically to see progress.
BUILDER_STATE_DIR = Path("/var/lib/sysdeck/builder/state")
BUILDER_LOGS_DIR = Path("/var/lib/sysdeck/builder/logs")
BUILDER_ARTIFACTS_DIR = Path("/var/lib/sysdeck/builder/artifacts")
# v0.1.4 SECURITY: build-ids and profile names are used to build paths
# under the three dirs above (state/<id>.json, logs/<id>.log,
# artifacts/<profile>/). They arrive as raw argv from the bridge caller,
# so they must be validated as a single safe path component before any
# filesystem use — otherwise `build-log ../../etc/foo` reads arbitrary
# *.log files, `build-delete <traversal>` unlinks arbitrary *.json/*.log,
# and `artifacts-clear /etc` would rmtree an arbitrary directory as root
# (found by the 0.3.0 security audit; every one of these now fails closed).
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
def _valid_id(token: str) -> bool:
"""True if token is a safe single path component (no separators,
no traversal, no leading dash, bounded length)."""
if not isinstance(token, str) or not token:
return False
return bool(_SAFE_ID_RE.match(token)) and ".." not in token
def _under_dir(path: Path, root: Path) -> bool:
"""True if (resolved) path stays inside root (defends symlinks + traversal)."""
try:
path.resolve().relative_to(root.resolve())
return True
except (ValueError, RuntimeError, OSError):
return False
def _ensure_state_dirs() -> None:
"""Create the state/logs/artifacts dirs. Best-effort; the cockpit
superuser channel handles root perms when needed."""
for d in (BUILDER_STATE_DIR, BUILDER_LOGS_DIR, BUILDER_ARTIFACTS_DIR):
try:
d.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError):
pass
def _new_build_id(profile: str) -> str:
"""Generate a build ID: <profile>-<YYYYMMDDHHMMSS>."""
import datetime
ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile)
return f"{safe_profile}-{ts}"
def _build_state_path(build_id: str) -> Path:
return BUILDER_STATE_DIR / f"{build_id}.json"
def _build_log_path(build_id: str) -> Path:
return BUILDER_LOGS_DIR / f"{build_id}.log"
def _read_build_state(build_id: str) -> dict[str, Any] | None:
p = _build_state_path(build_id)
try:
return json.loads(p.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, PermissionError, OSError):
return None
def _write_build_state(build_id: str, state: dict[str, Any]) -> None:
p = _build_state_path(build_id)
try:
_ensure_state_dirs()
p.write_text(json.dumps(state, indent=2), encoding="utf-8")
except (PermissionError, OSError):
pass
# ── Build invocation ────────────────────────────────────────────────
#
# Each backend has its own invocation pattern:
# mkosi run `mkosi build` in the profile's directory
# archiso run `mkarchiso <profile> build` in /usr/share/archiso/configs/<profile>
# vmdb2 run `vmdb2 <spec.yaml> --output <artifact>` in /tmp
# live-build run `lb build` in the profile's directory
#
# The bridge invokes the backend via subprocess and streams stdout
# and stderr to the log file. The build runs synchronously (the JS
# panel's cockpit.spawn will block until completion; for long builds
# the operator can navigate away and check back via build-status()).
def _migrate_legacy_mkosi_packages(conf_path: Path) -> dict[str, Any]:
"""Detect and rewrite old indented Packages= syntax to single-line.
mkosi v22+ (Arch ships 25.x) ONLY understands:
Packages=linux linux-firmware systemd openssh
The old v0.0.x indented form:
Packages=
linux
linux-firmware
systemd
openssh
is silently parsed by mkosi v22+ as a single package name with
embedded newlines ("linux\\nlinux-firmware\\n..."), which doesn't
exist in any repo — so mkosi installs NOTHING. The operator sees
a successful build with zero of their requested packages in the
image.
This function reads the profile, detects the old syntax, rewrites
the Packages= line to single-line space-separated form IN-PLACE,
and returns a status dict. If the file already uses modern syntax,
it's a no-op.
"""
try:
content = conf_path.read_text(encoding="utf-8") if conf_path.is_file() else ""
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
if "[Packages]" not in content:
return {"migrated": False, "reason": "no [Packages] section"}
# Match: "Packages=" followed by a newline and one or more
# indented lines (the old v0.0.x continuation syntax).
old_pat = re.compile(
r"^(Packages=)\s*\n((?:[ \t]+[^\s\n][^\n]*\n)+)",
re.MULTILINE,
)
m = old_pat.search(content)
if not m:
return {"migrated": False, "reason": "already uses single-line syntax"}
# Extract package names from the indented block.
packages: list[str] = []
for line in m.group(2).splitlines():
name = line.strip()
if name and name not in packages:
packages.append(name)
if not packages:
return {"migrated": False, "reason": "no packages found in indented block"}
# Rewrite: replace "Packages=\n pkg1\n pkg2\n" with
# "Packages=pkg1 pkg2"
new_line = f"Packages={' '.join(packages)}"
new_content = content[:m.start()] + new_line + content[m.end():]
try:
conf_path.write_text(new_content, encoding="utf-8")
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
return {"migrated": True, "count": len(packages), "packages": packages}
def _backend_build_command(backend_id: str, profile: dict[str, Any], options: dict[str, Any]) -> list[str]:
"""Build the backend invocation command for the given profile.
`options` may carry: output_dir, image_format, extra_args, force.
Returns the argv list to subprocess.run.
mkosi's --include flag includes a drop-in fragment ON TOP OF the
base mkosi.conf — it does NOT replace the base config. With no
mkosi.conf in the cwd, mkosi uses defaults and ignores the
--include file entirely.
Therefore build() creates a temp directory, symlinks the profile
file into it as `mkosi.conf`, and sets work_dir to that
temp dir. mkosi finds `mkosi.conf` (the symlink), follows it,
reads the actual profile. This works for ANY profile path
regardless of its filename or location.
v0.1.1: --output and --output-dir are ALWAYS passed on the CLI
so the output path is forced to the artifacts dir.
"""
ppath = profile.get("path", "")
pname = profile.get("name", "")
if backend_id == "mkosi":
artifacts_dir = options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname)
output_name = options.get("output_name") or f"{pname}.raw"
cmd = [
"mkosi", "build",
"--output", output_name,
"--output-dir", artifacts_dir,
]
if options.get("force", True):
cmd.append("--force")
if options.get("image_format"):
cmd += ["--format", options["image_format"]]
return cmd
if backend_id == "archiso":
return ["mkarchiso", ppath, "build"] if ppath else ["mkarchiso", "build"]
if backend_id == "vmdb2":
cmd = ["vmdb2", ppath] if ppath else ["vmdb2"]
out = options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname)
cmd += ["--output", out]
return cmd
if backend_id == "live-build":
return ["lb", "build"]
return []
# Build environment: PATH/locale pinned like every privileged helper in
# this suite, plus the proxy vars mkosi/vmdb2 legitimately need for
# image fetches. Everything else from the invoking session is dropped.
BUILD_ENV = {
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C", "LC_ALL": "C",
"HOME": "/root",
**{k: os.environ[k] for k in (
"http_proxy", "https_proxy", "no_proxy",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
) if k in os.environ},
}
# Build environment: PATH/locale pinned like every privileged helper in
# this suite, plus the proxy vars mkosi/vmdb2 legitimately need for
# image fetches. Everything else from the invoking session is dropped.
BUILD_ENV = {
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C", "LC_ALL": "C",
"HOME": "/root",
**{k: os.environ[k] for k in (
"http_proxy", "https_proxy", "no_proxy",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
) if k in os.environ},
}
def _prepare_mkosi_work_dir(profile: dict[str, Any]) -> Path | None:
"""Create a temp dir with mkosi.conf symlinked to the profile file.
v0.1.3: mkosi ONLY reads a file literally named `mkosi.conf` from
the cwd. --include includes a drop-in but does NOT replace the base
config. So we create a temp dir, symlink the profile as `mkosi.conf`,
and run mkosi there. The symlink ensures mkosi reads the REAL profile
file (not a stale copy), so any edits the operator makes are picked
up on the next build.
Returns the temp dir Path, or None if the profile has no path.
The caller is responsible for cleaning up the temp dir after the
build finishes.
"""
ppath = profile.get("path", "")
if not ppath:
return None
p = Path(ppath)
if not p.is_file():
return None
tmpdir = Path(tempfile.mkdtemp(prefix="sysdesk-mkosi-"))
link = tmpdir / "mkosi.conf"
try:
link.symlink_to(p.resolve())
except (OSError, FileExistsError):
# If symlink fails, copy the file instead.
import shutil as _sh
_sh.copy2(p, link)
return tmpdir
def _backend_profile_dir(backend_id: str, profile: dict[str, Any]) -> Path | None:
"""Return the working directory for the backend invocation."""
ppath = profile.get("path", "")
if not ppath:
return None
p = Path(ppath)
# mkosi.conf is a file — work in its parent.
if p.is_file():
return p.parent
return p
def build(args: list[str]) -> dict[str, Any]:
"""Run a build for the given profile.
Usage: build <profile-name> [backend] [--options json]
The profile is looked up via profiles(). If backend is omitted,
the profile's own backend field is used. Returns a build-id that
the JS panel can pass to build-status() / build-log().
The build runs synchronously. The cockpit superuser channel
handles root privileges via the org.sysdeck.builder.modify polkit
action (shipped since v0.0.17, v0.0.30 expanded for mkosi/vmdb2/
archiso/live-build).
"""
if not args:
return {"error": "profile name required"}
pname = args[0]
backend_override = args[1] if len(args) > 1 and not args[1].startswith("--") else None
# Parse --options json
options: dict[str, Any] = {}
for a in args[1:]:
if a.startswith("--options="):
try:
options = json.loads(a[len("--options="):])
except json.JSONDecodeError:
pass
# Find the profile.
all_profiles = profiles()
profile = None
for p in all_profiles:
if p.get("name") == pname:
profile = p
break
if profile is None:
return {"error": f"profile '{pname}' not found"}
backend_id = backend_override or profile.get("backend") or (primary["id"] if (primary := _primary_backend() or {}) else "")
# Verify backend is installed.
backend_installed = next((b for b in BACKENDS if b["id"] == backend_id), None)
if backend_installed is None:
return {"error": f"backend '{backend_id}' is not installed",
"hint": install_hint()}
# v0.1.1: detect OLD v0.0.x profiles in /etc/mkosi/mkosi.conf.d/.
profile_path = profile.get("path", "")
is_legacy_v050_profile = False
if backend_id == "mkosi" and profile_path:
try:
p_obj = Path(profile_path)
if "/etc/mkosi/mkosi.conf.d/" in str(p_obj) or \
(p_obj.parent.name == "mkosi.conf.d" and p_obj.name != "mkosi.conf"):
is_legacy_v050_profile = True
except (ValueError, TypeError):
pass
# v0.1.2: auto-migrate legacy indented Packages= syntax to
# single-line before building. mkosi v22+ can't parse the old
# v0.0.x indented form — it silently treats it as a single
# package name with embedded newlines, which doesn't exist in
# any repo, so ZERO packages get installed. This must happen
# BEFORE the build command is constructed so mkosi sees the
# correct syntax when --include= loads the file.
migration_result = None
if backend_id == "mkosi" and profile_path:
try:
p_obj = Path(profile_path)
if p_obj.is_file():
migration_result = _migrate_legacy_mkosi_packages(p_obj)
except Exception: # noqa: BLE001
migration_result = {"error": "migration failed unexpectedly"}
build_id = _new_build_id(pname)
log_path = _build_log_path(build_id)
# v0.1.3: for mkosi, create a temp work dir with mkosi.conf
# symlinked to the profile file. mkosi ONLY reads a file named
# `mkosi.conf` from the cwd — --include doesn't replace the base
# config. The temp dir ensures mkosi always finds and reads the
# profile regardless of its actual filename or location.
mkosi_temp_dir = None
if backend_id == "mkosi":
mkosi_temp_dir = _prepare_mkosi_work_dir(profile)
if mkosi_temp_dir is not None:
work_dir = mkosi_temp_dir
else:
work_dir = _backend_profile_dir(backend_id, profile)
else:
work_dir = _backend_profile_dir(backend_id, profile)
cmd = _backend_build_command(backend_id, profile, options)
if not cmd:
# Clean up temp dir before returning.
if mkosi_temp_dir:
shutil.rmtree(mkosi_temp_dir, ignore_errors=True)
return {"error": f"no build command for backend '{backend_id}'"}
# v0.1.1 SAFETY CHECK: verify the resolved output path is NOT under
# any system config directory. The check enforces that the output
# dir is either (a) the real BUILDER_ARTIFACTS_DIR (which lives under
# /var/lib/sysdeck/builder/artifacts/), or (b) under /var/lib/, /tmp/,
# or /var/tmp/. This blocks /etc/, /usr/, /boot/, /bin/, /sbin/,
# /lib/, /root/, /home/, etc. — anywhere a stray image.raw would
# corrupt the system or pollute a user's home.
if backend_id in ("mkosi", "vmdb2"):
resolved_output_dir = Path(options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname))
try:
resolved = resolved_output_dir.resolve()
except (OSError, RuntimeError):
resolved = resolved_output_dir
# Allow if it equals the patched BUILDER_ARTIFACTS_DIR/<name>
# (the test-suite patches BUILDER_ARTIFACTS_DIR to a tempdir).
expected_default = (BUILDER_ARTIFACTS_DIR / pname).resolve() \
if BUILDER_ARTIFACTS_DIR.is_absolute() else (BUILDER_ARTIFACTS_DIR / pname)
try:
is_default = (resolved == expected_default or
str(resolved) == str(expected_default))
except (OSError, ValueError):
is_default = False
# Allow under /var/lib/, /tmp/, /var/tmp/.
allowed_roots = (Path("/var/lib"), Path("/tmp"), Path("/var/tmp"))
is_under_allowed = False
for root in allowed_roots:
try:
resolved.relative_to(root)
is_under_allowed = True
break
except ValueError:
continue
if not (is_default or is_under_allowed):
return {
"error": (
f"refusing to build: output directory '{resolved_output_dir}' "
f"is not under /var/lib/, /tmp/, or /var/tmp/. Build outputs "
f"must go to /var/lib/sysdeck/builder/artifacts/<profile>/ to "
f"avoid corrupting system config directories."
),
"hint": "remove the output_dir override from your build options",
}
# Ensure the per-profile artifacts dir exists (mkosi won't
# create the parent dir for --output-dir itself).
try:
resolved_output_dir.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError) as exc:
return {"error": f"cannot create artifacts dir '{resolved_output_dir}': {exc}",
"hint": "run via cockpit superuser channel (polkit org.sysdeck.builder.modify)"}
# Write initial state.
import datetime
started = datetime.datetime.now().isoformat()
# v0.1.1: record the resolved output_dir in the build state so the
# panel and the log both make it clear where the image will land.
resolved_output_dir = None
if backend_id == "mkosi":
resolved_output_dir = str(options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname))
elif backend_id == "vmdb2":
resolved_output_dir = options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname)
state = {
"build_id": build_id,
"profile": pname,
"backend": backend_id,
"command": " ".join(cmd),
"work_dir": str(work_dir) if work_dir else None,
"output_dir": resolved_output_dir,
"started": started,
"finished": None,
"duration_s": None,
"state": "running",
"rc": None,
"artifacts": [],
"options": options,
"warnings": (
["legacy_v050_profile: profile is in /etc/mkosi/mkosi.conf.d/ — "
"mkosi may ignore it. Migrate to /etc/mkosi/profiles/<name>/mkosi.conf."]
if is_legacy_v050_profile else []
) + (
[f"packages_migrated: rewrote Packages= from old indented syntax "
f"to single-line ({migration_result['count']} packages: "
f"{', '.join(migration_result['packages'][:10])})"]
if migration_result and migration_result.get("migrated") else []
),
}
_write_build_state(build_id, state)
# Run the build. stdout+stderr → log file.
_ensure_state_dirs()
try:
with log_path.open("w", encoding="utf-8") as logf:
logf.write(f"$ {' '.join(cmd)}\n")
logf.write(f"# work_dir: {work_dir}\n")
logf.write(f"# backend: {backend_id}\n")
logf.write(f"# profile: {pname}\n")
if resolved_output_dir:
logf.write(f"# output_dir: {resolved_output_dir}\n")
if is_legacy_v050_profile:
logf.write("# WARNING: profile is in /etc/mkosi/mkosi.conf.d/ (legacy v0.0.x layout).\n")
logf.write("# --include= on the CLI forces mkosi to load it anyway.\n")
if migration_result and migration_result.get("migrated"):
pkgs = migration_result.get("packages", [])
shown = ", ".join(pkgs[:10])
suffix = f" (and {len(pkgs)-10} more)" if len(pkgs) > 10 else ""
logf.write(f"# MIGRATED: rewrote Packages= from old indented syntax to single-line\n")
logf.write(f"# ({migration_result['count']} packages: {shown}{suffix})\n")
elif migration_result and migration_result.get("error"):
logf.write(f"# MIGRATION ERROR: {migration_result['error']}\n")
logf.write("\n")
logf.flush()
r = subprocess.run(
cmd,
cwd=work_dir if work_dir else None,
stdout=logf, stderr=subprocess.STDOUT,
check=False, timeout=3600,
env=BUILD_ENV,
)
rc = r.returncode
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
rc = 127
try:
with log_path.open("a", encoding="utf-8") as logf:
logf.write(f"\n[bridge] build invocation failed: {exc}\n")
except (PermissionError, OSError):
pass
# Discover artifacts.
artifacts_dir = BUILDER_ARTIFACTS_DIR / pname
artifact_files: list[dict[str, Any]] = []
if artifacts_dir.is_dir():
for f in sorted(artifacts_dir.iterdir()):
if f.is_file():
try:
sz = f.stat().st_size
except OSError:
sz = 0
artifact_files.append({"name": f.name, "path": str(f), "size": sz})
# v0.1.3: clean up the temp work dir created for mkosi.
if mkosi_temp_dir:
shutil.rmtree(mkosi_temp_dir, ignore_errors=True)
# Update state.
finished = datetime.datetime.now().isoformat()
state["state"] = "succeeded" if rc == 0 else "failed"
state["rc"] = rc
state["finished"] = finished
state["artifacts"] = artifact_files
try:
started_dt = datetime.datetime.fromisoformat(started)
finished_dt = datetime.datetime.fromisoformat(finished)
state["duration_s"] = (finished_dt - started_dt).total_seconds()
except (ValueError, TypeError):
pass
_write_build_state(build_id, state)
return {
"build_id": build_id,
"profile": pname,
"backend": backend_id,
"rc": rc,
"success": rc == 0,
"state": state["state"],
"duration_s": state["duration_s"],
"artifacts": artifact_files,
"log_path": str(log_path),
}
# ── Profile creation / deletion ──────────────────────────────────────
#
# These scaffold a minimal profile config in the appropriate dir for
# the chosen backend. The operator is expected to edit the scaffolded
# file before building — the bridge writes the minimum required to
# make the backend discover the profile.
#
# v0.0.49: profile-create and profile-copy accept an optional inline
# package list (--packages=<json>) and a merge mode (--mode=append|
# replace). The package list is written to the backend-specific
# package file:
# mkosi → [Packages] section of <name>.conf
# vmdb2 → bootstrap.include list in <name>.yaml
# archiso → packages.x86_64 in the profile dir
# live-build → config/package-lists/sysdeck.list
def _extract_opts(args: list[str]) -> tuple[list[str], dict[str, str]]:
"""Split an argv list into (positional, opts).
Recognizes `--key=value` and `--key` (boolean, value="true").
Everything else is positional. This lets profile-create and
profile-copy accept optional --packages=<json> and --mode=append|
replace flags without breaking their existing positional
`<name> <backend> [base]` / `<src> <new> [backend]` signatures.
"""
positional: list[str] = []
opts: dict[str, str] = {}
for a in args:
if a.startswith("--") and "=" in a:
k, v = a[2:].split("=", 1)
opts[k] = v
elif a.startswith("--"):
opts[a[2:]] = "true"
else:
positional.append(a)
return positional, opts
def _parse_packages_text(text: str) -> list[str]:
"""Parse a multiline package-list text into a list of names.
Accepts one package per line. Lines starting with `#` are comments
and are stripped (the underlying backend file format may or may not
preserve them — archiso/live-build do, mkosi/vmdb2 don't). Empty
lines and surrounding whitespace are stripped. Duplicate names are
removed while preserving first-occurrence order.
"""
if not text:
return []
seen: set[str] = set()
out: list[str] = []
for line in text.splitlines():
# Strip comments: everything after a inline `#` is dropped too,
# matching archiso's packages.* format.
hash_idx = line.find("#")
if hash_idx >= 0:
line = line[:hash_idx]
name = line.strip()
if not name:
continue
if name in seen:
continue
seen.add(name)
out.append(name)
return out
_MKOSI_TEMPLATE = """[Distribution]
Distribution={distro}
Release={release}
[Output]
Format=disk
Output={name}.raw
OutputDirectory=/var/lib/sysdeck/builder/artifacts/{name}
[Packages]
Packages=linux linux-firmware systemd openssh
"""
_VMDB2_TEMPLATE = """# vmdb2 spec for {name}
# See https://vmdb2.rtfd.io for full schema.
image_size: 2G
image_format: raw
partitions:
- name: root
type: ext4
mountpoint: /
bootstrap:
- distro: debian
target: root
include:
- linux-image-amd64
- systemd
- openssh-server
commands:
- passwd -d root
"""
def profile_create(args: list[str]) -> dict[str, Any]:
"""Scaffold a new profile.
Usage: profile-create <name> <backend> [base] [--packages=<json>] [--mode=append|replace]
Creates the file at the backend's profile dir. For mkosi, writes
/etc/mkosi/mkosi.conf.d/<name>.conf. For vmdb2, writes
/etc/vmdb2/<name>.yaml. For archiso and live-build, returns an
error (those use shipped profile dirs the operator should copy
via profile-copy, not scaffold from scratch).
v0.0.49: if --packages=<json> is given, the JSON-decoded string is
parsed as a multiline package list (one per line, # comments ok)
and written to the backend-specific package file. --mode defaults
to "replace" for profile-create (the scaffold's defaults are
minimal; the operator's list replaces them).
"""
positional, opts = _extract_opts(args)
if len(positional) < 2:
return {"error": "usage: profile-create <name> <backend> [base] [--packages=<json>] [--mode=append|replace]"}
name = positional[0]
backend_id = positional[1]
base = positional[2] if len(positional) > 2 else None
# v0.1.4 SECURITY: the name becomes /etc/mkosi/profiles/<name>/ (or
# /etc/vmdb2/<name>.yaml) and is .format()-ed into the scaffold's
# config templates — a name containing '/', '..' or newlines is
# directory traversal plus arbitrary config-line injection into
# files that mkosi/vmdb2 later execute as root during builds.
if not _valid_id(name):
return {"error": "profile name must be a single path component "
"(letters, digits, '.', '_', '-'; no slashes, no '..', no newlines)"}
# Validate backend.
valid_backends = ("mkosi", "vmdb2")
if backend_id not in valid_backends:
return {"error": f"profile-create supports {valid_backends}; {backend_id} profiles are not scaffolded (use the shipped ones)"}
# Parse --packages and --mode.
packages_json = opts.get("packages")
packages_text = ""
if packages_json:
try:
packages_text = json.loads(packages_json)
if not isinstance(packages_text, str):
return {"error": "--packages JSON must decode to a string"}
except json.JSONDecodeError as exc:
return {"error": f"--packages is not valid JSON: {exc}"}
mode = opts.get("mode", "replace") # default for create
if backend_id == "mkosi":
# v0.1.0: each mkosi profile lives in its own directory
# /etc/mkosi/profiles/<name>/mkosi.conf. mkosi only reads a
# file literally named `mkosi.conf` from the cwd — the v0.0.x
# layout of /etc/mkosi/mkosi.conf.d/<name>.conf was a drop-in
# fragment that mkosi silently ignored without a parent
# mkosi.conf to layer onto. Per-profile directories also let
# operators drop in mkosi.pkg / mkosi.extra/ etc. naturally.
target_dir = Path("/etc/mkosi/profiles") / name
target_file = target_dir / "mkosi.conf"
try:
target_dir.mkdir(parents=True, exist_ok=True)
if target_file.exists():
return {"error": f"{target_file} already exists"}
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from __init__ import DISTRO # type: ignore[import-not-found]
except Exception: # noqa: BLE001
DISTRO = "unknown"
distro = "arch" if DISTRO == "arch" else "debian"
release = "rolling" if DISTRO == "arch" else "bookworm"
content = _MKOSI_TEMPLATE.format(distro=distro, release=release, name=name)
target_file.write_text(content, encoding="utf-8")
result: dict[str, Any] = {"created": True, "backend": backend_id, "name": name,
"path": str(target_file), "template": "mkosi.conf"}
# v0.0.49: write packages if provided.
if packages_text:
pkg_result = _write_packages(str(target_file), backend_id, packages_text, mode)
if "error" in pkg_result:
result["packages_error"] = pkg_result["error"]
else:
result["packages"] = pkg_result
return result
except (PermissionError, OSError) as exc:
return {"created": False, "error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.builder.modify)"}
if backend_id == "vmdb2":
target_dir = Path("/etc/vmdb2")
target_file = target_dir / f"{name}.yaml"
try:
target_dir.mkdir(parents=True, exist_ok=True)
if target_file.exists():
return {"error": f"{target_file} already exists"}
content = _VMDB2_TEMPLATE.format(name=name)
target_file.write_text(content, encoding="utf-8")
result = {"created": True, "backend": backend_id, "name": name,
"path": str(target_file), "template": "vmdb2-spec"}
# v0.0.49: write packages if provided.
if packages_text:
pkg_result = _write_packages(str(target_file), backend_id, packages_text, mode)
if "error" in pkg_result:
result["packages_error"] = pkg_result["error"]
else:
result["packages"] = pkg_result
return result
except (PermissionError, OSError) as exc:
return {"created": False, "error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.builder.modify)"}
return {"error": "unreachable"}
# ── Profile copy (archiso / live-build) ─────────────────────────────
#
# v0.0.48: profile-create only scaffolds single-file specs (mkosi.conf
# fragments and vmdb2 YAML). The directory-based backends — archiso
# and live-build — ship baseline profile trees under /usr/share that
# the operator is expected to *copy* into /etc/ and then edit, not
# scaffold from scratch. Without a copy path, the panel's Create
# Profile form would funnel operators of archiso-only or live-build-
# only hosts straight into the "profile-create supports ('mkosi',
# 'vmdb2')" error.
#
# profile-copy resolves a shipped source profile via the existing
# profiles() discovery, refuses if the destination already exists,
# and copies the tree with shutil.copytree. The operator then owns
# the /etc/ copy and can edit it before building.
def profile_copy(args: list[str]) -> dict[str, Any]:
"""Copy a shipped directory-based profile into /etc/.
Usage: profile-copy <src-name> <new-name> [backend] [--packages=<json>] [--mode=append|replace]
Resolves <src-name> through profiles() — typically a shipped
archiso baseline/releng or a live-build config dir under
/usr/share. Copies the whole tree to /etc/<backend>/configs/
<new-name>/ (archiso) or /etc/live-build/<new-name>/ (live-build).
Refuses to copy:
- profiles owned by mkosi or vmdb2 (single-file specs —
use profile-create to scaffold a new one),
- if the destination already exists,
- if the source path is not a directory.
The copy is a single shutil.copytree call; the cockpit superuser
channel handles root perms via the org.sysdeck.builder.modify
polkit action (same one profile-create uses).
v0.0.49: if --packages=<json> is given, the JSON-decoded string is
parsed as a multiline package list and written to the backend-
specific package file in the freshly-copied profile. --mode
defaults to "append" for profile-copy (the baseline's packages
like 'linux'/'base' are preserved; the operator's list adds to
them). Use --mode=replace to overwrite the baseline's package
file entirely.
"""
positional, opts = _extract_opts(args)
if len(positional) < 2:
return {"error": "usage: profile-copy <src-name> <new-name> [backend] [--packages=<json>] [--mode=append|replace]"}
src_name = positional[0]
new_name = positional[1]
backend_hint = positional[2] if len(positional) > 2 else None
# Reject obviously bad new-names before touching the filesystem.
if "/" in new_name or new_name in (".", ".."):
return {"error": f"invalid new-name '{new_name}' (must be a single path component)"}
# Parse --packages and --mode (default for copy is append).
packages_json = opts.get("packages")
packages_text = ""
if packages_json:
try:
packages_text = json.loads(packages_json)
if not isinstance(packages_text, str):
return {"error": "--packages JSON must decode to a string"}
except json.JSONDecodeError as exc:
return {"error": f"--packages is not valid JSON: {exc}"}
mode = opts.get("mode", "append") # default for copy
# Find the source profile via the unified discovery.
all_profiles = profiles()
src_profile: dict[str, Any] | None = None
for p in all_profiles:
if p.get("name") != src_name:
continue
if backend_hint and p.get("backend") != backend_hint:
continue
src_profile = p
break
if src_profile is None:
return {
"error": f"source profile '{src_name}' not found",
"hint": "use 'profiles' to list available profiles; "
"shipped archiso profiles are usually 'baseline' and 'releng'",
}
backend = src_profile.get("backend")
# Only directory-based backends are copyable. mkosi/vmdb2 use
# single files and have profile-create for scaffolding instead.
if backend not in ("archiso", "live-build"):
return {
"error": (
f"profile-copy is for archiso/live-build directory profiles; "
f"'{src_name}' is a {backend} profile "
f"(use profile-create to scaffold a new one)"
),
}
src_path = Path(src_profile.get("path", ""))
if not src_path.is_dir():
return {"error": f"source profile path {src_path} is not a directory"}
# Destination layout per backend:
# archiso → /etc/archiso/configs/<new-name>/
# live-build → /etc/live-build/<new-name>/
# (Roots are module-level constants ARCHISO_COPY_DEST /
# LIVE_BUILD_COPY_DEST so unit tests can patch them with tempdirs.)
if backend == "archiso":
dest_dir = ARCHISO_COPY_DEST / new_name
else: # live-build
dest_dir = LIVE_BUILD_COPY_DEST / new_name
if dest_dir.exists():
return {
"error": f"{dest_dir} already exists",
"hint": "pick a different name, or profile-delete the existing one first",
}
try:
import shutil
dest_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_path, dest_dir)
result: dict[str, Any] = {
"copied": True,
"backend": backend,
"source": src_name,
"source_path": str(src_path),
"name": new_name,
"path": str(dest_dir),
}
# v0.0.49: write packages if provided.
if packages_text:
pkg_result = _write_packages(str(dest_dir), backend, packages_text, mode)
if "error" in pkg_result:
result["packages_error"] = pkg_result["error"]
else:
result["packages"] = pkg_result
return result
except (PermissionError, OSError) as exc:
return {
"copied": False,
"error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.builder.modify)",
}
# ── Package-list writing (v0.0.49) ─────────────────────────────────
#
# Each backend stores its package list in a different place and format:
#
# mkosi → [Packages] section of <name>.conf (INI continuation)
# vmdb2 → bootstrap[].include list in <name>.yaml (YAML list)
# archiso → packages.x86_64 in the profile dir (one per line)
# live-build → config/package-lists/sysdeck.list (one per line)
#
# All four writers accept (path, packages_list, mode) where mode is
# "append" or "replace". They return a dict with the written path and
# the final package count so profile_create / profile_copy can surface
# it in their success response.
#
# The writers are intentionally per-backend (no generic "update INI"
# or "update YAML" abstraction) because each format has its own quirks
# (mkosi's indented continuation, vmdb2's nested include list,
# archiso's per-arch files, live-build's multi-file package-lists).
# A shared abstraction would leak format details through it.
def _write_packages_mkosi(conf_path: Path, packages: list[str], mode: str) -> dict[str, Any]:
"""Rewrite or extend the [Packages] section of a mkosi.conf file.
v0.1.0: mkosi v22+ (what Arch ships as mkosi 25.x) expects a single
space-separated `Packages=` line:
[Packages]
Packages=linux linux-firmware systemd openssh
The v0.0.x writer used indented continuation lines, which mkosi v22+
silently parsed as a single package named "linux\\nlinux-firmware..."
and failed to install. The old indented form is detected on read so
existing v0.0.x profiles migrate cleanly when next written.
"""
try:
existing = conf_path.read_text(encoding="utf-8") if conf_path.is_file() else ""
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
# Parse existing packages out of the [Packages] section so we can
# dedup on append. Accepts both the modern single-line form:
# Packages=a b c
# and the legacy v0.0.x indented continuation form:
# Packages=
# a
# b
existing_pkgs: list[str] = []
if "[Packages]" in existing:
in_packages = False
in_packages_value = False
for line in existing.splitlines():
if line.startswith("[") and line.endswith("]"):
in_packages = (line == "[Packages]")
in_packages_value = False
continue
if in_packages and line.strip().startswith("Packages="):
in_packages_value = True
# Modern form: Packages=a b c — capture inline.
inline = line.split("=", 1)[1].strip() if "=" in line else ""
if inline:
for name in inline.split():
if name and name not in existing_pkgs:
existing_pkgs.append(name)
continue
if in_packages and in_packages_value:
# Legacy continuation lines are indented. A non-indented
# line ends the continuation.
if line and not line[0].isspace():
in_packages_value = False
continue
name = line.strip()
if name and name not in existing_pkgs:
existing_pkgs.append(name)
if mode == "append":
# Dedup: keep existing, add new (preserving order).
seen = set(existing_pkgs)
for p in packages:
if p not in seen:
existing_pkgs.append(p)
seen.add(p)
final = existing_pkgs
else: # replace
final = packages
# Rebuild the [Packages] section as a single-line space-separated
# value. mkosi v22+ accepts this form unambiguously.
pkgs_line = " ".join(final)
new_section = f"[Packages]\nPackages={pkgs_line}"
if "[Packages]" in existing:
# Replace the existing [Packages] section up to the next [Section]
# or EOF.
lines = existing.splitlines(keepends=True)
out: list[str] = []
in_packages_section = False
for line in lines:
if line.startswith("[") and line.endswith("]\n"):
if line.strip() == "[Packages]":
in_packages_section = True
out.append(new_section + "\n")
else:
in_packages_section = False
out.append(line)
elif in_packages_section:
# Skip existing [Packages] body lines.
continue
else:
out.append(line)
new_content = "".join(out)
else:
# No [Packages] section yet — append one.
new_content = existing.rstrip("\n") + "\n\n" + new_section + "\n"
try:
conf_path.write_text(new_content, encoding="utf-8")
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
return {"path": str(conf_path), "count": len(final), "mode": mode}
def _write_packages_vmdb2(yaml_path: Path, packages: list[str], mode: str) -> dict[str, Any]:
"""Rewrite or extend the bootstrap.include list in a vmdb2 YAML.
The scaffolded YAML has a single bootstrap entry with an `include:`
list. We do regex-based surgery on that list — pyyaml is not a hard
dependency (vmdb2 itself isn't typically installed on Arch, and we
shouldn't pull in a YAML parser just to update a list).
"""
try:
existing = yaml_path.read_text(encoding="utf-8") if yaml_path.is_file() else ""
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
# Parse the existing include list. The pattern is:
# include:
# - linux-image-amd64
# - systemd
# - openssh-server
include_pat = re.compile(
r"(^(\s+)include:\s*\n)((?:\s+\- \S+\s*\n)+)",
re.MULTILINE,
)
existing_pkgs: list[str] = []
m = include_pat.search(existing)
if m:
for line in m.group(3).splitlines():
s = line.strip()
if s.startswith("- "):
name = s[2:].strip()
if name and name not in existing_pkgs:
existing_pkgs.append(name)
if mode == "append":
seen = set(existing_pkgs)
for p in packages:
if p not in seen:
existing_pkgs.append(p)
seen.add(p)
final = existing_pkgs
else: # replace
final = packages
# Rebuild the include block.
if m:
indent = m.group(2)
new_lines = m.group(1)
for p in final:
new_lines += f"{indent} - {p}\n"
new_content = existing[:m.start()] + new_lines + existing[m.end():]
else:
# No include: block found — append one under the bootstrap section.
# This is a fallback; the scaffolded YAML always has it.
indent = " "
block = f"\n{indent}include:\n"
for p in final:
block += f"{indent} - {p}\n"
new_content = existing.rstrip("\n") + "\n" + block
try:
yaml_path.write_text(new_content, encoding="utf-8")
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
return {"path": str(yaml_path), "count": len(final), "mode": mode}
def _write_packages_archiso(profile_dir: Path, packages: list[str], mode: str) -> dict[str, Any]:
"""Rewrite or extend packages.x86_64 in an archiso profile dir.
archiso's packages.<arch> files are one-package-per-line with
# comments allowed. We preserve comments in append mode (read
existing, add new at the end). In replace mode we write a fresh
file with a header comment + the new packages.
"""
pkg_file = profile_dir / "packages.x86_64"
try:
existing = pkg_file.read_text(encoding="utf-8") if pkg_file.is_file() else ""
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
if mode == "append":
# Parse existing package names (preserve comments + order).
existing_pkgs: list[str] = []
for line in existing.splitlines():
hash_idx = line.find("#")
if hash_idx >= 0:
line = line[:hash_idx]
name = line.strip()
if name and name not in existing_pkgs:
existing_pkgs.append(name)
seen = set(existing_pkgs)
for p in packages:
if p not in seen:
existing_pkgs.append(p)
seen.add(p)
final = existing_pkgs
# Preserve the existing file's comment header if present, then
# write the deduped package list.
header_lines = []
for line in existing.splitlines():
if line.strip().startswith("#"):
header_lines.append(line)
else:
break
new_lines = header_lines
if header_lines and header_lines[-1].strip() != "":
new_lines.append("")
for p in final:
new_lines.append(p)
new_content = "\n".join(new_lines) + "\n"
else: # replace
new_content = "# Package list (written by sysdeck-builder v0.0.49)\n"
for p in packages:
new_content += f"{p}\n"
try:
pkg_file.write_text(new_content, encoding="utf-8")
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
return {"path": str(pkg_file), "count": len(packages) if mode == "replace"
else len(final), "mode": mode}
def _write_packages_live_build(profile_dir: Path, packages: list[str], mode: str) -> dict[str, Any]:
"""Write config/package-lists/sysdeck.list in a live-build profile dir.
live-build merges all `config/package-lists/*.list` files at build
time, so each list file is an independent package set. In replace
mode we delete any existing sysdeck.list (and other operator-added
.list files in the same dir that were created by this command —
tracked via a header comment) and write a fresh one. In append mode
we just write/overwrite sysdeck.list (the file is the unit).
"""
lists_dir = profile_dir / "config" / "package-lists"
pkg_file = lists_dir / "sysdeck.list"
try:
lists_dir.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
if mode == "replace":
# Remove any existing sysdeck.list files (including ones we
# wrote previously under variant names like sysdeck-<n>.list).
# We do NOT touch other .list files that shipped with the
# baseline profile — those are the distro's responsibility.
try:
for f in lists_dir.glob("sysdeck*.list"):
f.unlink()
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
new_content = "# Package list (written by sysdeck-builder v0.0.49)\n"
for p in packages:
new_content += f"{p}\n"
try:
pkg_file.write_text(new_content, encoding="utf-8")
except (PermissionError, OSError) as exc:
return {"error": str(exc)}
return {"path": str(pkg_file), "count": len(packages), "mode": mode}
def _write_packages(profile_path: str, backend: str, packages_text: str, mode: str) -> dict[str, Any]:
"""Dispatch to the per-backend package writer.
`profile_path` is the path returned by profiles() (a file for
mkosi/vmdb2, a directory for archiso/live-build). `packages_text`
is the raw multiline text from the operator (textarea or uploaded
file content). `mode` is "append" or "replace".
Returns the writer's result dict on success, or {"error": ...} on
invalid backend / mode.
"""
if mode not in ("append", "replace"):
return {"error": f"invalid mode '{mode}' (must be 'append' or 'replace')"}
packages = _parse_packages_text(packages_text)
p = Path(profile_path)
if backend == "mkosi":
return _write_packages_mkosi(p, packages, mode)
if backend == "vmdb2":
return _write_packages_vmdb2(p, packages, mode)
if backend == "archiso":
if not p.is_dir():
return {"error": f"archiso profile path {p} is not a directory"}
return _write_packages_archiso(p, packages, mode)
if backend == "live-build":
if not p.is_dir():
return {"error": f"live-build profile path {p} is not a directory"}
return _write_packages_live_build(p, packages, mode)
return {"error": f"unknown backend '{backend}' for package writing"}
def profile_delete(args: list[str]) -> dict[str, Any]:
"""Delete a profile config file.
Usage: profile-delete <name>
Looks up the profile in the discovered profiles() list and removes
the file. Refuses to delete shipped profiles (under /usr/share).
"""
if not args:
return {"error": "profile name required"}
name = args[0]
all_profiles = profiles()
profile = None
for p in all_profiles:
if p.get("name") == name:
profile = p
break
if profile is None:
return {"error": f"profile '{name}' not found"}
ppath = profile.get("path")
if not ppath:
return {"error": "profile has no path"}
p = Path(ppath)
# Refuse to delete shipped profiles under /usr/share.
try:
p.resolve().relative_to(Path("/usr/share"))
return {"error": f"refusing to delete shipped profile {p} (under /usr/share)",
"hint": "ship your own profile under /etc/ instead"}
except ValueError:
pass
try:
if p.is_file():
p.unlink()
elif p.is_dir():
# For archiso/live-build profile dirs, refuse unless --force.
if "--force" not in args[1:]:
return {"error": f"refusing to delete profile directory {p} without --force"}
import shutil
shutil.rmtree(p)
else:
return {"error": f"profile path {p} is neither file nor dir"}
return {"deleted": True, "name": name, "path": ppath}
except (PermissionError, OSError) as exc:
return {"deleted": False, "error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.builder.modify)"}
# ── Host package-list import (v0.1.0) ───────────────────────────────
def _detect_host_packages() -> tuple[list[str], str]:
"""Return (explicitly-installed package list, distro_id) for the host.
v0.1.3 FIX: previous versions relied on `from __init__ import
PKG_MANAGER` which silently failed in the cockpit superuser
channel context (different Python path), causing PKG_MANAGER to
default to "unknown" and the host query to return an EMPTY list.
The operator saw "tries to build only 2" because the import wrote
nothing and the build used the profile's original template packages.
Now uses shutil.which() to find the package manager binary directly
— no import dependency, works in any execution context.
"""
# Try each package manager in order. First one found wins.
pacman_bin = shutil.which("pacman")
if pacman_bin:
cmd = [pacman_bin, "-Qqe"]
marker = "arch"
else:
apt_mark = shutil.which("apt-mark")
if apt_mark:
cmd = [apt_mark, "showmanual"]
marker = "debian"
else:
dnf_bin = shutil.which("dnf")
if dnf_bin:
cmd = [dnf_bin, "repoquery", "--userinstalled",
"--queryformat", "%{name}"]
marker = "fedora"
else:
return [], "unknown"
try:
r = subprocess.run(cmd, capture_output=True, text=True,
check=False, timeout=60)
except (FileNotFoundError, subprocess.TimeoutExpired):
return [], marker
# If the command failed (non-zero exit), return empty with the
# marker so the caller can report which distro was detected.
if r.returncode != 0:
return [], marker
pkgs: list[str] = []
seen: set[str] = set()
for line in r.stdout.splitlines():
name = line.strip()
if not name or name in seen:
continue
if name.startswith("#"):
continue
seen.add(name)
pkgs.append(name)
return pkgs, marker
def profile_import_packages(args: list[str]) -> dict[str, Any]:
"""Import the host's explicitly-installed packages into a profile.
Usage: profile-import-packages <name> [--mode=append|replace]
[--packages=<json>] [--dry-run]
Without --packages, queries the host package manager (pacman -Qqe on
Arch, apt-mark showmanual on Debian, dnf repoquery --userinstalled
on Fedora) and writes the result into the named profile's package
list via the existing _write_packages() dispatch.
--packages=<json> overrides the host query (JSON-decoded multiline
string) — useful for importing a list captured on a different host.
--mode defaults to "append" for this command (the operator usually
wants to layer host packages on top of the profile's existing
baseline). Pass --mode=replace to wipe the baseline first.
--dry-run returns what *would* be written without touching the
profile file. Useful for the JS panel's "Preview" affordance.
The package list is sanitized through _parse_packages_text (dedup,
comment stripping, whitespace trim) before being written.
"""
if not args:
return {"error": "profile name required"}
name = args[0]
positional, opts = _extract_opts(args)
# _extract_opts strips the leading positional too — re-grab name.
if not positional:
return {"error": "profile name required"}
name = positional[0]
mode = opts.get("mode", "append")
if mode not in ("append", "replace"):
return {"error": f"invalid mode '{mode}' (must be 'append' or 'replace')"}
dry_run = "dry-run" in opts
# Look up the profile so we can resolve (backend, path).
all_profiles = profiles()
profile = None
for p in all_profiles:
if p.get("name") == name:
profile = p
break
if profile is None:
return {"error": f"profile '{name}' not found"}
backend_id = profile.get("backend") or (primary["id"] if (primary := _primary_backend() or {}) else "")
ppath = profile.get("path", "")
if not ppath:
return {"error": f"profile '{name}' has no path"}
# Source: --packages override, or the host query.
packages_json = opts.get("packages")
if packages_json:
try:
packages_text = json.loads(packages_json)
if not isinstance(packages_text, str):
return {"error": "--packages JSON must decode to a string"}
except json.JSONDecodeError as exc:
return {"error": f"--packages is not valid JSON: {exc}"}
source = "manual"
host_distro = "n/a"
else:
packages_text, host_distro = _detect_host_packages()
# _detect_host_packages returns a list — re-render as multiline
# text so _write_packages / _parse_packages_text can handle it
# uniformly with the rest of the pipeline.
packages_text = "\n".join(packages_text)
source = f"host:{host_distro}"
if not packages_text.strip():
return {"error": f"no packages detected on host (distro={host_distro})",
"hint": "install pacman/apt/dnf, or pass --packages=<json> manually"}
# Dry-run: return what would be written, don't touch the file.
parsed = _parse_packages_text(packages_text)
if dry_run:
return {
"profile": name,
"backend": backend_id,
"source": source,
"host_distro": host_distro,
"mode": mode,
"dry_run": True,
"package_count": len(parsed),
"packages": parsed[:200], # cap to keep JSON sane
"truncated": len(parsed) > 200,
}
# Write via the shared dispatch.
result = _write_packages(ppath, backend_id, packages_text, mode)
if "error" in result:
return result
return {
"imported": True,
"profile": name,
"backend": backend_id,
"source": source,
"host_distro": host_distro,
"mode": mode,
**result,
}
# ── Build status / log / artifacts ──────────────────────────────────
def build_status(_args: list[str] = None) -> list[dict[str, Any]]:
"""List active and recently-finished builds.
Reads the state files under BUILDER_STATE_DIR and returns them
sorted by started timestamp descending. The JS panel uses this
to render a Builds table.
"""
if not BUILDER_STATE_DIR.is_dir():
return []
out: list[dict[str, Any]] = []
for sf in BUILDER_STATE_DIR.glob("*.json"):
try:
state = json.loads(sf.read_text(encoding="utf-8"))
out.append(state)
except (json.JSONDecodeError, PermissionError, OSError):
continue
out.sort(key=lambda s: s.get("started", ""), reverse=True)
return out
def build_log(args: list[str]) -> dict[str, Any]:
"""Return the contents of a build's log file.
Usage: build-log <build-id>
Returns the full log text (capped at 1MB to avoid blowing up the
JSON response for huge builds). The JS panel renders this in a
<pre>.
"""
if not args:
return {"error": "build-id required"}
build_id = args[0]
# v0.1.4 SECURITY: build-id is used to build the log path under
# BUILDER_LOGS_DIR — a traversal id would read arbitrary *.log files.
if not _valid_id(build_id):
return {"error": "invalid build-id (must be a single path component)"}
log_path = _build_log_path(build_id)
if not log_path.is_file():
return {"error": f"no log file for build {build_id}", "build_id": build_id}
try:
text = log_path.read_text(encoding="utf-8", errors="replace")
# Cap at 1MB.
if len(text) > 1_000_000:
text = f"[truncated — log is {len(text)} bytes, showing last 1MB]\n" + text[-1_000_000:]
return {"build_id": build_id, "log": text, "path": str(log_path)}
except (PermissionError, OSError) as exc:
return {"error": str(exc), "build_id": build_id}
def artifacts(args: list[str]) -> dict[str, Any]:
"""List build artifacts.
Usage: artifacts [profile]
Without a profile arg, lists artifacts across all profiles.
With a profile arg, lists artifacts for that profile only.
"""
if not BUILDER_ARTIFACTS_DIR.is_dir():
return {"artifacts": [], "by_profile": {}}
profile_filter = args[0] if args else None
# v0.1.4 SECURITY: a traversal profile filter would list an
# arbitrary directory's contents (names/sizes/mtimes) to the browser.
if profile_filter and (not _valid_id(profile_filter) or
not _under_dir(BUILDER_ARTIFACTS_DIR / profile_filter, BUILDER_ARTIFACTS_DIR)):
return {"error": "invalid profile filter (must be a single path component)"}
by_profile: dict[str, list[dict[str, Any]]] = {}
if profile_filter:
profiles_to_scan = [BUILDER_ARTIFACTS_DIR / profile_filter]
else:
profiles_to_scan = [p for p in BUILDER_ARTIFACTS_DIR.iterdir() if p.is_dir()]
for prof_dir in profiles_to_scan:
if not prof_dir.is_dir():
continue
files: list[dict[str, Any]] = []
for f in sorted(prof_dir.iterdir()):
if not f.is_file():
continue
try:
sz = f.stat().st_size
import datetime
mtime = datetime.datetime.fromtimestamp(f.stat().st_mtime).isoformat()
except OSError:
sz, mtime = 0, None
files.append({"name": f.name, "path": str(f), "size": sz, "modified": mtime})
by_profile[prof_dir.name] = files
return {"by_profile": by_profile, "artifacts": sum(len(v) for v in by_profile.values())}
def artifact_delete(args: list[str]) -> dict[str, Any]:
"""Delete a single build artifact file.
Usage: artifact-delete <profile> <name>
Removes /var/lib/sysdeck/builder/artifacts/<profile>/<name>.
Refuses to delete files outside the artifacts dir (safety).
"""
if len(args) < 2:
return {"error": "usage: artifact-delete <profile> <name>"}
profile = args[0]
name = args[1]
# Resolve the artifact path safely.
prof_dir = BUILDER_ARTIFACTS_DIR / profile
target = (prof_dir / name).resolve()
try:
target.relative_to(BUILDER_ARTIFACTS_DIR.resolve())
except (ValueError, RuntimeError):
return {"error": f"refusing to delete: path '{target}' is not under {BUILDER_ARTIFACTS_DIR}"}
if not target.is_file():
return {"error": f"artifact not found: {target}"}
try:
sz = target.stat().st_size
target.unlink()
except (PermissionError, OSError) as exc:
return {"error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.builder.modify)"}
return {"deleted": True, "profile": profile, "name": name, "path": str(target), "size": sz}
def artifacts_clear(args: list[str]) -> dict[str, Any]:
"""Delete ALL artifacts for a profile.
Usage: artifacts-clear <profile>
Removes /var/lib/sysdeck/builder/artifacts/<profile>/ entirely.
"""
if not args:
return {"error": "usage: artifacts-clear <profile>"}
profile = args[0]
# v0.1.4 SECURITY: profile is used to rmtree a directory as root —
# an absolute path ('/etc') or traversal ('../..') escapes the
# artifacts root. Validate as a single component AND resolve the
# target under BUILDER_ARTIFACTS_DIR (same guard artifact-delete
# has had since v0.0.31; artifacts-clear missed it).
if not _valid_id(profile) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile, BUILDER_ARTIFACTS_DIR):
return {"error": f"refusing to clear: '{profile}' is not a profile directory under {BUILDER_ARTIFACTS_DIR}"}
prof_dir = BUILDER_ARTIFACTS_DIR / profile
if not prof_dir.is_dir():
return {"error": f"no artifacts directory for profile '{profile}'"}
# Count files before deletion.
file_count = 0
total_size = 0
for f in prof_dir.rglob("*"):
if f.is_file():
try:
total_size += f.stat().st_size
file_count += 1
except OSError:
pass
try:
shutil.rmtree(prof_dir)
except (PermissionError, OSError) as exc:
return {"error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.builder.modify)"}
return {"cleared": True, "profile": profile, "files_deleted": file_count,
"bytes_freed": total_size}
def build_delete(args: list[str]) -> dict[str, Any]:
"""Delete a build's state + log files, optionally its artifacts.
Usage: build-delete <build-id> [--artifacts]
Removes state + log for the build. With --artifacts, also clears
the profile's entire artifacts dir.
v0.1.3: reads the state file FIRST (to get the profile name for
artifact cleanup) before deleting it.
"""
if not args:
return {"error": "build-id required"}
build_id = args[0]
# v0.1.4 SECURITY: build-id builds state/log paths that get unlinked
# as root — a traversal id would delete arbitrary *.json/*.log files.
if not _valid_id(build_id):
return {"error": "invalid build-id (must be a single path component)"}
delete_artifacts = "--artifacts" in args[1:]
deleted = []
errors = []
# Read state file first to get the profile name (for artifact cleanup).
state_file = BUILDER_STATE_DIR / f"{build_id}.json"
profile_name = None
if state_file.is_file():
try:
state_data = json.loads(state_file.read_text(encoding="utf-8"))
profile_name = state_data.get("profile")
except (json.JSONDecodeError, PermissionError, OSError):
pass
# Delete state file.
if state_file.is_file():
try:
state_file.unlink()
deleted.append(str(state_file))
except (PermissionError, OSError) as exc:
errors.append(f"state: {exc}")
# Delete log file.
log_file = BUILDER_LOGS_DIR / f"{build_id}.log"
if log_file.is_file():
try:
log_file.unlink()
deleted.append(str(log_file))
except (PermissionError, OSError) as exc:
errors.append(f"log: {exc}")
# Optionally delete artifacts.
if delete_artifacts and profile_name:
# v0.1.4 SECURITY: profile_name comes from the (deleted) state
# file's JSON — treat it as untrusted before rmtree'ing with it.
if not _valid_id(profile_name) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile_name, BUILDER_ARTIFACTS_DIR):
errors.append(f"artifacts: refusing to clear untrusted profile path {profile_name!r}")
else:
prof_dir = BUILDER_ARTIFACTS_DIR / profile_name
if prof_dir.is_dir():
try:
shutil.rmtree(prof_dir)
deleted.append(str(prof_dir) + "/ (artifacts dir)")
except (PermissionError, OSError) as exc:
errors.append(f"artifacts: {exc}")
if not deleted and not errors:
return {"error": f"no build found with id '{build_id}'"}
return {"deleted": True, "build_id": build_id, "profile": profile_name,
"files": deleted, "errors": errors}
COMMANDS = {
# v0.0.30 viewer subcommands (kept):
"status": lambda _args: status(),
"profiles": lambda _args: profiles(),
"summary": lambda _args: summary(),
"backends": lambda _args: backends(),
"install-hint": lambda _args: install_hint(),
# v0.0.31 full-featured subcommands:
"build": lambda args: build(args),
"profile-create": lambda args: profile_create(args),
"profile-copy": lambda args: profile_copy(args),
"profile-delete": lambda args: profile_delete(args),
# v0.1.0: import host's explicitly-installed packages into a profile.
"profile-import-packages": lambda args: profile_import_packages(args),
"build-status": lambda args: build_status(args),
"build-log": lambda args: build_log(args),
"build-delete": lambda args: build_delete(args),
"artifacts": lambda args: artifacts(args),
# v0.1.3: artifact management — delete + clear.
"artifact-delete": lambda args: artifact_delete(args),
"artifacts-clear": lambda args: artifacts_clear(args),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))