chore: preserve laptop work before checkout migration [skip ci]

This commit is contained in:
Codex committed 2026-11-06 21:28:27 +03:00
1 parent 7ad4473e93
commit d83bad9d34
25 files changed
+1060 -86

No files matched your search

+47 -2
View File
@@ -7,12 +7,44 @@ non-`backup` subcommand passes straight through to restic, so init/snapshots/for
behave exactly as before."""
import json
import os
from pathlib import Path
import time
from engine.descriptor import Meta, Arg
METADATA = Meta(summary="Run the resticontainer label-driven backup (init-if-needed, backup, forget --prune).",
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
TITLE = "Restic Host Backup"
def write_backup_status(path, state, timestamp):
path = Path(path)
previous = json.loads(path.read_text()) if path.exists() else {}
status = {"state": state, "updated_at": timestamp,
"last_success": timestamp if state == "success" else previous.get("last_success", 0)}
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + f".{os.getpid()}.tmp")
temporary.write_text(json.dumps(status) + "\n")
temporary.chmod(0o644)
temporary.replace(path)
def restore_containers(ctx, container_ids):
"""Recover previously running backup targets, retrying namespace dependents."""
pending = list(container_ids)
if not pending:
return
for _ in range(len(pending) + 1):
cp = ctx.sys.run(["docker", "inspect", *pending], capture=True)
stopped = [c["Id"] for c in json.loads(cp.stdout) if not c["State"]["Running"]]
if not stopped:
return
progress = False
for container_id in stopped:
cp = ctx.sys.run(["docker", "start", container_id], check=False, capture=True)
progress = progress or cp.returncode == 0
if not progress:
raise RuntimeError("Backup left containers stopped: " + ", ".join(stopped))
pending = stopped
raise RuntimeError("Backup containers did not stay running: " + ", ".join(pending))
def run(ctx, args):
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
core = ctx.paths.core
@@ -42,6 +74,8 @@ def run(ctx, args):
except (ValueError, TypeError):
return None
status_path = Path("/var/lib/apex/backup-status.json")
write_backup_status(status_path, "running", time.time())
try:
log.info("Checking restic repository availability...")
if restic("snapshots", check=False).returncode == 0:
@@ -50,8 +84,16 @@ def run(ctx, args):
ctx.notify.error(TITLE, url, "restic init failed"); raise SystemExit(1)
before = host_snapshot_ids()
log.info("Backing up `restic.*`-labelled services (resticontainer discovers paths)...")
if restic("backup", "--host", host, "--tag", tag, "--compression", "max",
check=False).returncode != 0:
targets = ctx.sys.run(["docker", "ps", "-q", "--no-trunc",
"--filter", "label=restic.enable=true",
"--filter", "label=restic.backup.stop=true"], capture=True).stdout.split()
try:
result = restic("backup", "--host", host, "--tag", tag, "--compression", "max",
check=False)
finally:
# Resticontainer may start a network-namespace dependent before its owner.
restore_containers(ctx, targets)
if result.returncode != 0:
ctx.notify.error(TITLE, url, "restic backup failed"); raise SystemExit(1)
# Guard: the label-driven backup is a silent no-op if no service carries the
# `restic.enable` label (resticontainer finds zero paths and skips restic).
@@ -70,9 +112,12 @@ def run(ctx, args):
log.success("Restic backup completed.")
if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}"):
raise SystemExit(1) # bash exited 1 when the success telegram failed to send
write_backup_status(status_path, "success", time.time())
except SystemExit:
write_backup_status(status_path, "failed", time.time())
raise
except Exception as e:
write_backup_status(status_path, "failed", time.time())
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"backup failed: {e}")
raise SystemExit(1) from e
+77
View File
@@ -0,0 +1,77 @@
"""Deploy the opt-in native Alloy service using shared host/guest configs."""
import json
from pathlib import Path
import re
import tempfile
from engine.descriptor import Flag, Meta
METADATA = Meta(summary="Configure native Alloy from configs/alloy/config.json.",
args=[Flag("--dry-run", "Validate the deployment plan without changes.")])
def deployment(ctx):
settings = Path(ctx.paths.configs) / "alloy/config.json"
if not settings.exists():
return None
values = json.loads(settings.read_text())
role = values.get("role")
if role not in ("host", "guest"):
raise ValueError("Alloy role must be host or guest")
env_file = values.get("environment_file", "")
if not re.fullmatch(r"/[A-Za-z0-9_./-]+", env_file):
raise ValueError("Alloy environment_file must be an absolute path without whitespace")
source = Path(ctx.paths.commons) / "etc/alloy" / (
"native-host.alloy" if role == "host" else "native.alloy")
config = source.read_text()
extra = Path(ctx.paths.configs) / "alloy/extra.alloy"
if extra.is_file():
config += "\n" + extra.read_text()
unit = ("[Service]\nEnvironmentFile=" + env_file + "\n"
'ExecStart=\nExecStart=/usr/bin/alloy run --storage.path=/var/lib/alloy --server.http.listen-addr=127.0.0.1:12346 /etc/alloy/config.alloy\n'
+ ("User=root\nGroup=root\n" if role == "guest" else "") +
"SupplementaryGroups=systemd-journal" + (" docker" if role == "guest" else "") + "\n")
return config, unit, env_file
def run(ctx, args):
plan = deployment(ctx)
if plan is None:
ctx.log.info("Native Alloy is not configured for this node; skipping.")
return
config, unit, env_file = plan
if args.dry_run:
ctx.log.info("Native Alloy deployment plan validated; no changes made.")
return
ctx.sys.sudo(["test", "-f", env_file])
# Validate with the actual service environment without printing credentials.
validator = '''import os, stat, subprocess, sys
from pathlib import Path
# Environment files deliberately use plain KEY=value, with no shell evaluation.
secret = Path(sys.argv[1])
info = secret.stat()
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o077:
raise RuntimeError('Alloy environment file must be root-owned with mode 0600 or stricter')
for line in secret.read_text().splitlines():
if not line.strip() or line.lstrip().startswith('#'): continue
key, value = line.split('=', 1)
os.environ[key] = value
subprocess.run(['alloy', 'validate', sys.argv[2]], check=True)
'''
with tempfile.TemporaryDirectory(prefix="apex-alloy-") as directory:
config_path = Path(directory) / "config.alloy"
unit_path = Path(directory) / "apex.conf"
config_path.write_text(config)
unit_path.write_text(unit)
ctx.sys.sudo(["python3", "-c", validator, env_file, str(config_path)])
ctx.sys.sudo(["install", "-d", "-m", "0755", "/etc/alloy",
"/etc/systemd/system/alloy.service.d"])
ctx.sys.sudo(["install", "-m", "0644", str(config_path), "/etc/alloy/config.alloy"])
ctx.sys.sudo(["install", "-m", "0644", str(unit_path),
"/etc/systemd/system/alloy.service.d/apex.conf"])
ctx.sys.sudo(["systemctl", "unmask", "alloy.service"])
ctx.sys.daemon_reload()
ctx.sys.sudo(["systemctl", "enable", "alloy.service"])
ctx.sys.sudo(["systemctl", "restart", "alloy.service"])
ctx.sys.sudo(["systemctl", "is-active", "--quiet", "alloy.service"])
ctx.log.success("Configured native Alloy.")
+30 -2
View File
@@ -1,8 +1,8 @@
#cloud-config
# Supply this file to cloud-init when booting the golden image.
# Replace the hostname, repository and public key. Keep filled files outside Git.
# SSH repositories additionally require approved deferred id_ed25519 (0600)
# and known_hosts (0644) writes under /home/adam/.ssh, owned by adam:adam.
# SSH repositories require deferred private-key (0600) and known_hosts (0644)
# writes under /home/adam/.ssh, owned by adam:adam. See the optional entries below.
# Supply trusted Git host keys; do not infer trust from ssh-keyscan alone.
preserve_hostname: false
hostname: node1.example.com
@@ -20,12 +20,40 @@ write_files:
content: |
APEX_REPO_NAME="node1.example.com"
APEX_REPO_URL="https://github.com/you/node1.example.com.git"
# Optional: APEX_ZFS_MODE="required", "auto", or "disabled".
# Omit for profile default: Debian amd64=required, raspberrypi5=auto.
# disabled rejects create/import tiers; existing ZFS is never removed.
APEX_TIER1_MODE="directory"
APEX_TIER1_DEVICE=""
APEX_TIER2_MODE="directory"
APEX_TIER2_DEVICE=""
APEX_TIER3_MODE="directory"
APEX_TIER3_DEVICE=""
# Optional SSH repository credentials (uncomment and fill all three entries).
# Set APEX_REPO_URL="git@github.com:you/node1.example.com.git" above.
# Paste file CONTENTS, not local file paths. Keep the filled file outside Git.
# - path: /home/adam/.ssh/node1.example.com_git@github.com:ed25519
# owner: adam:adam
# permissions: '0600'
# defer: true
# content: |
# -----BEGIN OPENSSH PRIVATE KEY-----
# REPLACE_WITH_PRIVATE_KEY_CONTENT
# -----END OPENSSH PRIVATE KEY-----
# - path: /home/adam/.ssh/config
# owner: adam:adam
# permissions: '0600'
# defer: true
# content: |
# Host github.com
# IdentityFile /home/adam/.ssh/node1.example.com_git@github.com:ed25519
# IdentitiesOnly yes
# - path: /home/adam/.ssh/known_hosts
# owner: adam:adam
# permissions: '0644'
# defer: true
# content: |
# REPLACE_WITH_VERIFIED_GITHUB_HOST_KEY_ENTRIES
# For a verified empty disk, explicitly select MODE=create and DEVICE=/dev/disk/by-id/...
# For an existing single-device pool, select MODE=import, DEVICE and POOL_GUID.
# Occupied devices are never erased; missing devices never fall back to directories.
+1
View File
@@ -79,6 +79,7 @@ build {
scripts = [
"ci/packer/scripts/01-base-system.sh",
"ci/packer/scripts/02-storage-docker.sh",
"ci/packer/scripts/02-alloy-package.sh",
"ci/packer/scripts/03-bootstrap-helper.sh",
"ci/packer/scripts/04-cleanup.sh"
]
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
# Service configuration and credentials are deployed later by apex configure/alloy.
alloy_version=1.21.3
alloy_sha256=dbbd705de8ab37dec0cbd6361a3fc7e754d6b2854b57ce672a83e6366199ba73
[[ "$(dpkg --print-architecture)" == amd64 ]]
alloy_deb=$(mktemp --suffix=.deb)
trap 'rm -f "$alloy_deb"' EXIT
curl --fail --location --retry 3 --output "$alloy_deb" \
"https://github.com/grafana/alloy/releases/download/v${alloy_version}/alloy-${alloy_version}-1.amd64.deb"
printf '%s %s\n' "$alloy_sha256" "$alloy_deb" | sha256sum --check --status
systemctl mask alloy.service
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$alloy_deb"
systemctl disable --now alloy.service
systemctl unmask alloy.service
! systemctl is-active --quiet alloy.service
! systemctl is-enabled --quiet alloy.service
+42 -41
View File
@@ -16,6 +16,9 @@ import tempfile
import urllib.request
import uuid
from .profiles import get_profile
from .state import ProvisionError
class BaselineError(RuntimeError):
"""A required baseline postcondition could not be established."""
@@ -73,30 +76,29 @@ def run(args, *, check=True, timeout=300):
return result
def preflight(*, strict=True):
def preflight(*, strict=True, profile=None):
if os.geteuid() != 0:
raise BaselineError("Baseline requires root.")
if not strict:
if not Path("/etc/debian_version").is_file():
raise BaselineError("Base policy requires a Debian-based system.")
return
if not Path("/etc/os-release").is_file():
raise BaselineError("Supported target is Debian 13 amd64 only.")
release = dict(line.split("=", 1) for line in Path("/etc/os-release").read_text().splitlines()
if "=" in line and not line.startswith("#"))
if (release.get("ID", "").strip('"') != "debian" or
release.get("VERSION_ID", "").strip('"') != "13" or
run(["dpkg", "--print-architecture"]).stdout.strip() != "amd64"):
raise BaselineError("Supported target is Debian 13 amd64 only.")
try:
(profile or get_profile()).validate(
lambda: run(['dpkg', '--print-architecture']).stdout.strip(),
lambda path: Path(path).read_text())
except ProvisionError as error:
raise BaselineError(str(error)) from None
def package_versions():
architecture = run(['dpkg', '--print-architecture']).stdout.strip()
result = run(["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\t${Version}\n"])
installed = {}
for line in result.stdout.splitlines():
fields = line.split("\t")
if len(fields) == 3 and fields[1].split()[1:] == ["ok", "installed"]:
installed[fields[0].removesuffix(":amd64")] = fields[2]
installed[fields[0].removesuffix(':' + architecture)] = fields[2]
return installed
@@ -311,31 +313,24 @@ def docker_preflight(installed):
return {**config, **DOCKER_CONFIG}
def configure_sources():
write_managed("/etc/apt/sources.list.d/apex-zfs.sources",
"Types: deb\nURIs: https://deb.debian.org/debian\nSuites: trixie\n"
"Components: contrib\nArchitectures: amd64\n"
"Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg\n")
for name, key_url, uri, suite, component in (
("docker", "https://download.docker.com/linux/debian/gpg",
"https://download.docker.com/linux/debian", "trixie", "stable"),
("crowdsec", "https://packagecloud.io/crowdsec/crowdsec/gpgkey",
"https://packagecloud.io/crowdsec/crowdsec/any", "any", "main"),
):
key = Path(f"/etc/apt/keyrings/apex-{name}.asc")
if not key.exists():
def configure_sources(profile=None, *, zfs=True):
profile = profile or get_profile()
for source in profile.sources:
if source.requires_zfs and not zfs:
continue
key = Path(source.keyring)
if source.key_url and not key.exists():
try:
# Both repository key URLs above are fixed HTTPS endpoints.
with urllib.request.urlopen(key_url, timeout=30) as response: # noqa: S310
with urllib.request.urlopen(source.key_url, timeout=30) as response: # noqa: S310
data = response.read()
except OSError as exc:
raise BaselineError(f"Cannot download {name} repository signing key.") from exc
raise BaselineError(f"Cannot download {source.name} repository signing key.") from exc
if not data.startswith(b"-----BEGIN PGP PUBLIC KEY BLOCK-----"):
raise BaselineError(f"Invalid {name} repository signing key.")
raise BaselineError(f"Invalid {source.name} repository signing key.")
write_managed(key, data)
write_managed(f"/etc/apt/sources.list.d/apex-{name}.sources",
f"Types: deb\nURIs: {uri}\nSuites: {suite}\nComponents: {component}\n"
f"Architectures: amd64\nSigned-By: {key}\n")
write_managed(f"/etc/apt/sources.list.d/apex-{source.name}.sources",
f"Types: deb\nURIs: {source.uri}\nSuites: {source.suite}\nComponents: {source.component}\n"
f"Architectures: {profile.architecture}\nSigned-By: {key}\n")
def ensure_service(name):
@@ -429,20 +424,23 @@ def apply_base_policy():
@expected_errors
def apply_baseline():
def apply_baseline(profile=None, spec=None):
"""Converge baseline and return verified package versions and capabilities."""
preflight()
profile = profile or get_profile()
preflight(profile=profile)
needs_zfs = profile.zfs_required(spec)
installed = package_versions()
docker_config = docker_preflight(installed)
kernel = run(["uname", "-r"]).stdout.strip()
if not re.fullmatch(r"[A-Za-z0-9.+_-]+", kernel):
raise BaselineError("Unsupported running kernel release.")
zfs_packages = ((*[name.format(kernel=kernel) for name in profile.headers],
'zfsutils-linux', 'zfs-dkms') if needs_zfs else ())
packages = (*BASE_PACKAGES, *ADMIN_PACKAGES, *DOCKER_PACKAGES,
*SECURITY_PACKAGES, "linux-headers-amd64", f"linux-headers-{kernel}",
"zfsutils-linux", "zfs-dkms")
*SECURITY_PACKAGES, *zfs_packages)
with service_start_guard(), security_service_guard(packages):
install_missing((*BASE_PACKAGES, *ADMIN_PACKAGES))
configure_sources()
configure_sources(profile, zfs=needs_zfs)
install_missing(("crowdsec",))
install_missing(packages)
repair_bouncer_credentials()
@@ -459,19 +457,22 @@ def apply_baseline():
compose = run(["docker", "compose", "version", "--short"]).stdout.strip()
if not compose:
raise BaselineError("Docker Compose capability is unavailable.")
module = run(["modprobe", "zfs"], check=False)
if module.returncode:
raise BaselineError(f"ZFS unavailable for running kernel {kernel}; install matching headers/module or reboot into the supported kernel, then retry. No automatic reboot was performed.")
zfs = run(["zfs", "version"]).stdout.strip()
zfs = None
if needs_zfs:
module = run(["modprobe", "zfs"], check=False)
if module.returncode:
raise BaselineError(f"ZFS unavailable for running kernel {kernel}; install matching headers/module or reboot into the supported kernel, then retry. No automatic reboot was performed.")
zfs = run(["zfs", "version"]).stdout.strip()
versions = package_versions()
if set(packages) - versions.keys():
raise BaselineError("Baseline package verification failed.")
return {"packages": {name: versions[name] for name in packages},
return {"profile": profile.name, "zfs_mode": (spec.zfs_mode if spec else None) or profile.default_zfs,
"packages": {name: versions[name] for name in packages},
"capabilities": {"docker_data_root": root, "compose": compose, "zfs": zfs,
"kernel": kernel, "ufw_docker_sha256": HELPER_SHA256},
"sources": {"docker": "docker.com/debian trixie stable",
"crowdsec": "packagecloud.io/crowdsec/crowdsec/any any main",
"zfs": "Debian trixie contrib"}}
"zfs": "Debian trixie contrib" if needs_zfs else None}}
if __name__ == "__main__":
+52 -4
View File
@@ -42,6 +42,7 @@ class Enrollment:
tiers: Tuple[Tier, ...]
authorized_keys: Tuple[str, ...] = field(repr=False)
files: Tuple[ManagedFile, ...] = field(repr=False)
zfs_mode: Optional[str] = None
def identity(self) -> Dict[str, Any]:
return {
@@ -59,7 +60,7 @@ class Enrollment:
}
_ENV_KEYS = {'APEX_REPO_NAME', 'APEX_REPO_URL', 'APEX_REPO_REF'} | {
_ENV_KEYS = {'APEX_REPO_NAME', 'APEX_REPO_URL', 'APEX_REPO_REF', 'APEX_ZFS_MODE'} | {
f'APEX_TIER{number}_{suffix}'
for number in (1, 2, 3)
for suffix in ('DEVICE', 'MODE', 'POOL_GUID')
@@ -71,7 +72,9 @@ _FILE_POLICY = {
'/etc/apex/bootstrap.env': ('root:root', 0o600, False),
'/home/adam/.ssh/id_ed25519': ('adam:adam', 0o600, True),
'/home/adam/.ssh/known_hosts': ('adam:adam', 0o644, True),
'/home/adam/.ssh/config': ('adam:adam', 0o600, True),
}
_NAMED_IDENTITY = re.compile(r'/home/adam/\.ssh/[A-Za-z0-9][A-Za-z0-9._@+-]{0,245}:ed25519')
def _require(condition: bool, diagnostic: str) -> None:
@@ -92,6 +95,35 @@ def _plain(value: Any, diagnostic: str, allow_empty: bool = False) -> str:
return value
def _ssh_config(content, identities):
selected, hosts, fields = set(), set(), set()
for line in content.splitlines():
parts = line.partition('#')[0].split()
if not parts:
continue
_require(len(parts) == 2, 'SSH config requires literal Host, IdentityFile and IdentitiesOnly entries')
key, value = parts[0].lower(), parts[1]
if key == 'host':
_require(not hosts or fields == {'identityfile', 'identitiesonly'},
'each SSH Host requires IdentityFile and IdentitiesOnly yes')
_require(bool(re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]*', value)) and value not in hosts,
'SSH Host must be one unique literal hostname')
hosts.add(value)
fields = set()
else:
_require(bool(hosts) and key in ('identityfile', 'identitiesonly') and key not in fields,
'SSH config supports only Host, IdentityFile and IdentitiesOnly')
if key == 'identityfile':
_require(value in identities, 'SSH IdentityFile must name an enrolled private key')
selected.add(value)
else:
_require(value == 'yes', 'SSH IdentitiesOnly must be yes')
fields.add(key)
_require(bool(hosts) and fields == {'identityfile', 'identitiesonly'},
'each SSH Host requires IdentityFile and IdentitiesOnly yes')
return selected
def parse_bootstrap_env(text: str) -> Dict[str, str]:
_require(type(text) is str, 'bootstrap.env must contain literal assignments')
result = {}
@@ -217,10 +249,13 @@ def normalize_user_data(document: Any) -> Enrollment:
item = _mapping(write, {'path', 'owner', 'permissions', 'content'}, {'defer'},
'unsupported write_files entry')
path = _plain(item['path'], 'managed file path is invalid')
_require(path in _FILE_POLICY and path not in seen,
policy = _FILE_POLICY.get(path)
if policy is None and _NAMED_IDENTITY.fullmatch(path):
policy = ('adam:adam', 0o600, True)
_require(policy is not None and path not in seen,
'managed file path is unsupported or repeated')
seen.add(path)
owner, mode, deferred = _FILE_POLICY[path]
owner, mode, deferred = policy
permission = item['permissions']
_require(type(permission) is str and bool(re.fullmatch(r'0?[0-7]{3}', permission))
and int(permission, 8) == mode and item['owner'] == owner,
@@ -230,7 +265,20 @@ def normalize_user_data(document: Any) -> Enrollment:
_require(type(content) is str and bool(content.strip()) and '\x00' not in content,
'managed file content is invalid')
files.append(ManagedFile(path, owner, mode, content))
named = {f.path for f in files if _NAMED_IDENTITY.fullmatch(f.path)}
ssh_config = next((f for f in files if f.path == '/home/adam/.ssh/config'), None)
_require(not named or ssh_config is not None, 'named SSH identities require an SSH config')
if ssh_config is not None:
identities = named | {f.path for f in files if f.path == '/home/adam/.ssh/id_ed25519'}
selected = _ssh_config(ssh_config.content, identities)
_require(named <= selected, 'named SSH identities must be selected in the SSH config')
bootstrap = next((f for f in files if f.path == '/etc/apex/bootstrap.env'), None)
_require(bootstrap is not None, 'bootstrap.env is required')
values = parse_bootstrap_env(bootstrap.content)
return Enrollment(hostname, _repository(values), _tiers(values), authorized_keys, tuple(files))
zfs_mode = values.get('APEX_ZFS_MODE')
_require(zfs_mode is None or zfs_mode in ('required', 'auto', 'disabled'),
'APEX_ZFS_MODE must be required, auto or disabled')
tiers = _tiers(values)
_require(zfs_mode != 'disabled' or all(t.mode == 'directory' for t in tiers),
'APEX_ZFS_MODE=disabled conflicts with ZFS storage tiers')
return Enrollment(hostname, _repository(values), tiers, authorized_keys, tuple(files), zfs_mode)
@@ -0,0 +1,117 @@
"""Built-in declarative enrollment profiles and their shared validation rules."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import shlex
from .state import ProvisionError
@dataclass(frozen=True)
class Source:
name: str
uri: str
suite: str
component: str
keyring: str
key_url: str = ''
requires_zfs: bool = False
SOURCES = (
Source('zfs', 'https://deb.debian.org/debian', 'trixie', 'contrib',
'/usr/share/keyrings/debian-archive-keyring.gpg', requires_zfs=True),
Source('docker', 'https://download.docker.com/linux/debian', 'trixie', 'stable',
'/etc/apt/keyrings/apex-docker.asc', 'https://download.docker.com/linux/debian/gpg'),
Source('crowdsec', 'https://packagecloud.io/crowdsec/crowdsec/any', 'any', 'main',
'/etc/apt/keyrings/apex-crowdsec.asc', 'https://packagecloud.io/crowdsec/crowdsec/gpgkey'),
)
@dataclass(frozen=True)
class Profile:
name: str
description: str
architecture: str
headers: tuple[str, ...]
default_zfs: str
os_id: str = 'debian'
version: str = '13'
codename: str = ''
model_prefix: str = ''
image_marker: str = ''
sources: tuple[Source, ...] = SOURCES
def shell_check(self):
checks = [
'. /etc/os-release',
f'test "${{ID:-}}" = {shlex.quote(self.os_id)}',
f'test "${{VERSION_ID:-}}" = {shlex.quote(self.version)}',
f'test "$(dpkg --print-architecture)" = {shlex.quote(self.architecture)}',
]
if self.codename:
checks.append(f'test "${{VERSION_CODENAME:-}}" = {shlex.quote(self.codename)}')
for path, prefix in (('/proc/device-tree/model', self.model_prefix),
('/etc/rpi-issue', self.image_marker)):
if prefix:
checks.append(f'grep -aq {shlex.quote("^" + prefix)} {shlex.quote(path)}')
return ' && '.join(checks)
def validate(self, architecture, read_text=None):
read_text = read_text or (lambda path: Path(path).read_text())
try:
release = dict(line.split('=', 1) for line in read_text('/etc/os-release').splitlines()
if '=' in line and not line.startswith('#'))
valid = (release.get('ID', '').strip('"') == self.os_id
and release.get('VERSION_ID', '').strip('"') == self.version)
if self.codename:
valid = valid and release.get('VERSION_CODENAME', '').strip('"') == self.codename
if valid:
valid = architecture() == self.architecture
for path, prefix in (('/proc/device-tree/model', self.model_prefix),
('/etc/rpi-issue', self.image_marker)):
if valid and prefix:
valid = read_text(path).startswith(prefix)
except OSError:
valid = False
if not valid:
raise ProvisionError('Selected profile requires ' + self.description)
def zfs_required(self, spec=None):
mode = (spec.zfs_mode if spec else None) or self.default_zfs
needed = bool(spec and any(t.mode != 'directory' for t in spec.tiers))
if mode == 'disabled' and needed:
raise ProvisionError('APEX_ZFS_MODE=disabled conflicts with ZFS storage tiers')
return mode == 'required' or (mode == 'auto' and needed)
DEFAULT_PROFILE = 'debian13-amd64'
PROFILES = {
'debian13-amd64': Profile('debian13-amd64', 'Debian 13 amd64', 'amd64',
('linux-headers-amd64', 'linux-headers-{kernel}'), 'required'),
'raspberrypi5': Profile('raspberrypi5', 'Raspberry Pi 5 with Raspberry Pi OS Trixie arm64',
'arm64', ('linux-headers-rpi-2712', 'linux-headers-{kernel}'), 'auto',
codename='trixie',
model_prefix='Raspberry Pi 5 Model B', image_marker='Raspberry Pi reference'),
}
def get_profile(name=DEFAULT_PROFILE):
try:
return PROFILES[name]
except (KeyError, TypeError):
raise ProvisionError('Unknown initialization profile') from None
def render_shell_profiles():
lines = ['# Generated by python3 -m provisioning.profiles; do not edit.',
'apex_check_profile() {', ' case "${1:-' + DEFAULT_PROFILE + '}" in']
for profile in PROFILES.values():
lines.append(' ' + profile.name + ') ' + profile.shell_check() + ' ;;')
lines += [' *) return 1 ;;', ' esac', '}', '']
return '\n'.join(lines)
if __name__ == '__main__':
print(render_shell_profiles(), end='')
@@ -0,0 +1,8 @@
# Generated by python3 -m provisioning.profiles; do not edit.
apex_check_profile() {
case "${1:-debian13-amd64}" in
debian13-amd64) . /etc/os-release && test "${ID:-}" = debian && test "${VERSION_ID:-}" = 13 && test "$(dpkg --print-architecture)" = amd64 ;;
raspberrypi5) . /etc/os-release && test "${ID:-}" = debian && test "${VERSION_ID:-}" = 13 && test "$(dpkg --print-architecture)" = arm64 && test "${VERSION_CODENAME:-}" = trixie && grep -aq '^Raspberry Pi 5 Model B' /proc/device-tree/model && grep -aq '^Raspberry Pi reference' /etc/rpi-issue ;;
*) return 1 ;;
esac
}
@@ -6,11 +6,21 @@ from pathlib import Path
import pwd
import subprocess
from engine.identity import read_env
from . import ENROLLMENT_INTERFACE
from .state import ProvisionError, safe_path
def read_node_identity(path):
values = {}
if path.is_file():
for line in path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def as_adam(args, cwd=None):
environment = dict(os.environ, GIT_TERMINAL_PROMPT='0',
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
@@ -88,7 +98,7 @@ def reconcile_repository(spec, state, save, srv=Path('/srv')):
if as_adam(['git', 'status', '--porcelain'], destination):
raise ProvisionError('checkout contains local changes; initialization will not reset it')
node_env = safe_path(destination / 'node.env')
node_identity = read_env(str(node_env))
node_identity = read_node_identity(node_env)
if node_identity.get('APEX_NODE_FQDN') != spec.hostname:
raise ProvisionError('repository node.env does not declare the requested hostname')
if node_identity.get('APEX_ENROLLMENT_INTERFACE') != str(ENROLLMENT_INTERFACE):
+3 -4
View File
@@ -3,10 +3,9 @@
set -euo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
[[ $(id -u) == 0 ]] || { echo 'APEX seed requires root.' >&2; exit 1; }
[[ -r /etc/os-release ]] || { echo 'APEX seed requires Debian 13 amd64.' >&2; exit 1; }
. /etc/os-release
[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 && $(dpkg --print-architecture) == amd64 ]] || {
echo 'APEX seed requires Debian 13 amd64.' >&2; exit 1;
source "$(dirname "${BASH_SOURCE[0]}")/profiles.sh"
apex_check_profile "${1:-debian13-amd64}" || {
echo 'APEX seed target does not match the selected profile.' >&2; exit 1;
}
policy=/usr/sbin/policy-rc.d
+7 -1
View File
@@ -82,7 +82,7 @@ def check_heartbeat(path, now=None):
class Journal:
def __init__(self, path, identity, revision, bundle_digest):
def __init__(self, path, identity, revision, bundle_digest, *, policy=None):
self.path = safe_path(path)
if self.path.exists():
try:
@@ -95,10 +95,16 @@ class Journal:
raise ProvisionError('enrollment identity conflicts with the recorded target')
if self.data.get('bundle_digest') != bundle_digest:
raise ProvisionError('provisioner bundle changed; resume using the recorded bundle')
recorded_policy = self.data.get('policy', {
'profile': 'debian13-amd64', 'zfs_mode': 'required', 'zfs_enabled': True})
if policy is not None and recorded_policy != policy:
raise ProvisionError('enrollment policy conflicts with the recorded target')
else:
self.data = dict(identity=identity, revision=revision, bundle_digest=bundle_digest,
baseline_revision=revision, enrollment_interface=ENROLLMENT_INTERFACE,
status='running', stages={})
if policy is not None:
self.data['policy'] = policy
def save(self):
atomic_write(self.path, json.dumps(self.data, sort_keys=True, indent=2) + '\n')
+9 -3
View File
@@ -413,10 +413,16 @@ def _swap(journal, save):
_require(filesystem in ('ext4', 'xfs'), 'optional swap requires a supported ext4 or XFS filesystem')
match = re.search(r'^MemTotal:\s+(\d+)\s+kB$', MEMINFO.read_text(), re.M)
_require(match is not None, 'RAM size could not be established')
size = (4 if int(match.group(1)) * 1024 > 4 * _GIB else 2) * _GIB
space = os.statvfs(SWAPFILE.parent)
_require(space.f_bavail * space.f_frsize >= size + _GIB,
'insufficient free space for optional swap and reserve')
capacity = space.f_blocks * space.f_frsize
available = space.f_bavail * space.f_frsize
reserve = max((capacity + 9) // 10, 2 * _GIB)
increment = _GIB // 4
size = min(int(match.group(1)) * 1024 // 8, capacity // 32,
4 * _GIB, available - reserve)
size = size // increment * increment
_require(size >= increment,
'insufficient RAM, filesystem capacity or free space for optional swap and reserve')
_require(hasattr(os, 'posix_fallocate'), 'safe swap allocation is unavailable')
journal['swap'] = {'status': 'intent', 'size': size}
save()
+19 -15
View File
@@ -5,7 +5,6 @@ import argparse
import json
import os
from pathlib import Path
import platform
import pwd
import subprocess
import sys
@@ -13,6 +12,7 @@ import tempfile
from .state import Journal, ProvisionError, atomic_write, exclusive_lock, safe_path
from .userdata import decode_user_data
from .profiles import DEFAULT_PROFILE, PROFILES, get_profile
STATE = Path('/var/lib/apex-init')
@@ -24,16 +24,11 @@ def run(args, **kwargs):
return result.stdout.strip()
def preflight():
def preflight(profile=DEFAULT_PROFILE):
if os.geteuid() != 0:
raise ProvisionError('target enrollment requires root privileges')
release = {}
for line in Path('/etc/os-release').read_text().splitlines():
key, separator, value = line.partition('=')
if separator:
release[key] = value.strip('"')
if release.get('ID') != 'debian' or release.get('VERSION_ID') != '13' or platform.machine() != 'x86_64':
raise ProvisionError('initialization supports Debian 13 amd64 only')
get_profile(profile).validate(lambda: run(['dpkg', '--print-architecture']),
lambda path: Path(path).read_text())
def validate_keys(spec):
@@ -86,21 +81,26 @@ def administrator(spec):
return {'administrator': 'adam', 'hostname': spec.hostname, 'sudo': 'verified'}
def enroll(spec, revision, bundle_digest, heartbeat=None):
def enroll(spec, revision, bundle_digest, heartbeat=None, profile=DEFAULT_PROFILE):
from .baseline import apply_baseline
from .storage import acknowledge_storage_ownership, reconcile_storage
from .repository import reconcile_repository
preflight()
preflight(profile)
selected = get_profile(profile)
selected.zfs_required(spec)
validate_keys(spec)
safe_path(STATE).mkdir(mode=0o700, exist_ok=True)
if STATE.stat().st_uid != 0:
raise ProvisionError('enrollment state directory must be root-owned')
os.chmod(STATE, 0o700)
with exclusive_lock(STATE / 'init.lock'):
journal = Journal(STATE / 'enrollment.json', spec.identity(), revision, bundle_digest)
policy = {'profile': profile, 'zfs_mode': spec.zfs_mode or selected.default_zfs,
'zfs_enabled': selected.zfs_required(spec)}
journal = Journal(STATE / 'enrollment.json', spec.identity(), revision, bundle_digest,
policy=policy)
journal.save()
journal.stage('baseline', apply_baseline, heartbeat)
journal.stage('baseline', lambda: apply_baseline(selected, spec), heartbeat)
journal.stage('administrator', lambda: administrator(spec), heartbeat)
result = journal.stage('storage', lambda: reconcile_storage(spec, journal.data, journal.save), heartbeat)
account = pwd.getpwnam('adam')
@@ -117,13 +117,14 @@ def enroll(spec, revision, bundle_digest, heartbeat=None):
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument('operation', choices=('validate', 'enroll', 'status', 'complete'))
parser.add_argument('--profile', choices=tuple(PROFILES), default=DEFAULT_PROFILE)
parser.add_argument('--user-data')
parser.add_argument('--revision', default='unknown')
parser.add_argument('--bundle-digest', default='unknown')
parser.add_argument('--heartbeat')
args = parser.parse_args(argv)
try:
preflight()
preflight(args.profile)
if args.operation == 'status':
path = safe_path(STATE / 'enrollment.json')
print(path.read_text() if path.exists() else json.dumps({'status': 'not-started'}))
@@ -133,6 +134,8 @@ def main(argv=None):
access = json.loads(safe_path(STATE / 'access/state.json').read_text())
path = safe_path(STATE / 'enrollment.json')
record = json.loads(path.read_text())
if record.get('policy', {}).get('profile', DEFAULT_PROFILE) != args.profile:
raise ProvisionError('Selected profile conflicts with recorded enrollment')
if access.get('status') != 'committed' or record.get('status') not in ('enrolled', 'complete'):
raise ProvisionError('enrollment and access must be verified before completion')
record['external_access'] = 'verified'
@@ -148,11 +151,12 @@ def main(argv=None):
if not path.is_file() or path.stat().st_size > 1024 * 1024:
raise ProvisionError('user-data must be a regular file no larger than 1 MiB')
spec = decode_user_data(path.read_bytes())
get_profile(args.profile).zfs_required(spec)
validate_keys(spec)
if args.operation == 'validate':
print(json.dumps({'status': 'valid', 'hostname': spec.hostname}))
else:
print(json.dumps(enroll(spec, args.revision, args.bundle_digest, args.heartbeat), sort_keys=True))
print(json.dumps(enroll(spec, args.revision, args.bundle_digest, args.heartbeat, args.profile), sort_keys=True))
return 0
except Exception as error:
from .contract import ContractError
@@ -9,12 +9,17 @@ import unittest
from unittest.mock import patch
from provisioning import baseline
from provisioning.profiles import get_profile
from provisioning.contract import normalize_user_data
from test_provision_contract import example, add_env
class Host:
def __init__(self, root):
self.root = root
self.commands = []
self.architecture = "amd64"
self.kernel = "6.12.0-amd64"
self.installed = {}
self.partial = {}
self.active = set()
@@ -42,12 +47,12 @@ class Host:
self.commands.append(args)
code, output = 0, ''
if args[:2] == ['dpkg', '--print-architecture']:
output = 'amd64\n'
output = self.architecture + '\n'
elif args[0] == 'dpkg-query':
output = ''.join(f'{name}\tinstall ok installed\t{version}\n' for name, version in self.installed.items())
output += ''.join(f'{name}\t{status}\t1.0\n' for name, status in self.partial.items())
elif args == ['uname', '-r']:
output = '6.12.0-amd64\n'
output = self.kernel + '\n'
elif args[0] == 'apt-get' and 'install' in args:
if '--simulate' in args:
output = self.plan
@@ -505,6 +510,58 @@ class BaselineTests(unittest.TestCase):
baseline.apply_baseline()
self.assertEqual([call.args[0] for call in command.call_args_list], [['dpkg', '--print-architecture']])
def test_pi_directory_baseline_uses_arm64_sources_without_zfs_or_headers(self):
self.host.architecture = 'arm64'
self.host.kernel = '6.12.75+rpt-rpi-2712'
self.host.zfs_available = False
spec = normalize_user_data(example())
with patch.object(baseline, 'preflight'):
result = baseline.apply_baseline(get_profile('raspberrypi5'), spec)
self.assertIsNone(result['capabilities']['zfs'])
for name in ('docker', 'crowdsec'):
content = self.host.path(f'/etc/apt/sources.list.d/apex-{name}.sources').read_text()
self.assertIn('Architectures: arm64\n', content)
self.assertNotIn('amd64', content)
self.assertFalse(self.host.path('/etc/apt/sources.list.d/apex-zfs.sources').exists())
self.assertFalse(any(name.startswith(('zfs', 'linux-headers')) for name in self.host.installed))
self.assertNotIn(['modprobe', 'zfs'], self.host.commands)
self.assertEqual(result['capabilities']['docker_data_root'], '/srv/docker')
def test_pi_explicit_zfs_uses_matching_pi_headers(self):
self.host.architecture = 'arm64'
self.host.kernel = '6.12.75+rpt-rpi-2712'
spec = normalize_user_data(add_env(example(), 'APEX_ZFS_MODE=required\n'))
with patch.object(baseline, 'preflight'):
result = baseline.apply_baseline(get_profile('raspberrypi5'), spec)
self.assertIn('linux-headers-rpi-2712', result['packages'])
self.assertIn('linux-headers-6.12.75+rpt-rpi-2712', result['packages'])
self.assertNotIn('linux-headers-amd64', result['packages'])
self.assertIn('Architectures: arm64',
self.host.path('/etc/apt/sources.list.d/apex-zfs.sources').read_text())
self.assertIn(['modprobe', 'zfs'], self.host.commands)
def test_debian_can_skip_zfs_and_dpkg_suffixes_are_normalized(self):
self.host.architecture = 'arm64'
self.host.installed['curl:arm64'] = '7.0'
self.host.installed['curl:armhf'] = '8.0'
self.assertEqual(baseline.package_versions()['curl'], '7.0')
self.assertEqual(baseline.package_versions()['curl:armhf'], '8.0')
self.host.installed.clear()
self.host.architecture = 'amd64'
for mode in ('auto', 'disabled'):
with self.subTest(mode=mode), patch.object(baseline, 'preflight'):
spec = normalize_user_data(add_env(example(), f'APEX_ZFS_MODE={mode}\n'))
result = baseline.apply_baseline(get_profile(), spec)
self.assertIsNone(result['capabilities']['zfs'])
self.assertNotIn('zfs-dkms', self.host.installed)
def test_foreign_package_does_not_skip_native_installation(self):
self.host.architecture = 'arm64'
self.host.installed['curl:armhf'] = '7.0'
self.assertEqual(baseline.install_missing(['curl']), ['curl'])
self.assertEqual(self.host.installed['curl'], '1.0')
self.assertEqual(self.host.installed['curl:armhf'], '7.0')
if __name__ == '__main__':
unittest.main()
@@ -82,6 +82,21 @@ class TestUserDataContract(unittest.TestCase):
self.assertEqual([tier.mode for tier in spec.tiers], ['directory'] * 3)
self.assertEqual(spec.tiers[0].number, 1)
def test_zfs_policy_defaults_overrides_and_conflicts(self):
from provisioning.profiles import get_profile
self.assertTrue(get_profile().zfs_required(normalize_user_data(example())))
self.assertFalse(get_profile('raspberrypi5').zfs_required(normalize_user_data(example())))
for mode, expected in (('required', True), ('auto', False), ('disabled', False)):
spec = normalize_user_data(add_env(example(), f'APEX_ZFS_MODE={mode}\n'))
for profile in ('debian13-amd64', 'raspberrypi5'):
self.assertEqual(get_profile(profile).zfs_required(spec), expected)
block = 'APEX_TIER1_MODE=create\nAPEX_TIER1_DEVICE=/dev/disk/by-id/spare\n'
spec = normalize_user_data(add_env(example(), block))
self.assertTrue(get_profile('raspberrypi5').zfs_required(spec))
for value in ('disabled', '', 'unknown'):
with self.subTest(value=value), self.assertRaises(ContractError):
normalize_user_data(add_env(example(), block + f'APEX_ZFS_MODE={value}\n'))
def test_ref_and_explicit_storage_intent(self):
doc = add_env(example(),
'APEX_REPO_REF="refs/tags/v1"\n'
@@ -202,6 +217,52 @@ class TestUserDataContract(unittest.TestCase):
self.assertNotIn('HOST_TRUST_SENTINEL', text)
self.assertEqual(spec.files[1].content, 'SECRET_SENTINEL')
def test_named_identity_is_selected_by_ssh_config(self):
import subprocess
import tempfile
from pathlib import Path
key = '/home/adam/.ssh/talos.apex.ermnvldmr.com_git@github.com:ed25519'
config = f'Host github.com\n IdentityFile {key}\n IdentitiesOnly yes\n'
doc = example()
for path, content in ((key, 'PRIVATE_TEST_KEY'), ('/home/adam/.ssh/config', config)):
doc['write_files'].append({'path': path, 'owner': 'adam:adam',
'permissions': '0600', 'defer': True, 'content': content})
spec = normalize_user_data(doc)
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / 'config'
target.write_text(next(f.content for f in spec.files if f.path.endswith('/config')))
result = subprocess.run(['ssh', '-G', '-F', str(target), 'git@github.com'],
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('identityfile ' + key, result.stdout.splitlines())
self.assertIn('identitiesonly yes', result.stdout.splitlines())
self.assertNotIn('PRIVATE_TEST_KEY', repr(spec) + repr(spec.identity()))
missing_config = copy.deepcopy(doc)
missing_config['write_files'].pop()
with self.assertRaisesRegex(ContractError, 'SSH config'):
normalize_user_data(missing_config)
for field, value in (('permissions', '0644'), ('owner', 'root:root'), ('defer', False)):
changed = copy.deepcopy(doc)
changed['write_files'][1][field] = value
with self.subTest(field=field), self.assertRaises(ContractError):
normalize_user_data(changed)
for path in ('/home/adam/.ssh/../escape:ed25519', '/home/adam/.ssh/sub/key:ed25519',
'/home/adam/.ssh/authorized_keys', '/home/adam/.ssh/key with spaces:ed25519'):
changed = copy.deepcopy(doc)
changed['write_files'][1]['path'] = path
with self.subTest(path=path), self.assertRaises(ContractError):
normalize_user_data(changed)
for text in (f'Host github.com\n IdentityFile {key}\n ProxyCommand echo unsafe\n',
f'IdentityFile {key}\n',
'Host github.com\n IdentityFile /etc/shadow\n IdentitiesOnly yes\n',
f'Host *\n IdentityFile {key}\n IdentitiesOnly yes\n',
f'Host github.com\n IdentityFile {key}\n IdentitiesOnly no\n',
f'Host github.com\n IdentityFile {key}\n'):
changed = copy.deepcopy(doc)
changed['write_files'][2]['content'] = text
with self.subTest(text=text), self.assertRaises(ContractError):
normalize_user_data(changed)
def test_administrator_files_require_defer(self):
doc = example()
doc['write_files'].append({
@@ -0,0 +1,58 @@
from pathlib import Path
import shlex
import subprocess
import tempfile
import unittest
from provisioning.profiles import get_profile, render_shell_profiles
from provisioning.state import ProvisionError
class ProfileTests(unittest.TestCase):
def test_profile_predicates_reject_unsupported_targets_in_python_and_seed_shell(self):
cases = (
('debian13-amd64', 'debian', '13', 'trixie', 'amd64', '', '', True),
('debian13-amd64', 'debian', '13', 'trixie', 'arm64', '', '', False),
('debian13-amd64', 'ubuntu', '13', 'trixie', 'amd64', '', '', False),
('raspberrypi5', 'debian', '13', 'trixie', 'arm64', 'Raspberry Pi 5 Model B Rev 1.0\x00',
'Raspberry Pi reference 2026-10-06\n', True),
('raspberrypi5', 'debian', '12', 'bookworm', 'arm64', 'Raspberry Pi 5 Model B',
'Raspberry Pi reference\n', False),
('raspberrypi5', 'debian', '13', 'bookworm', 'arm64', 'Raspberry Pi 5 Model B',
'Raspberry Pi reference\n', False),
('raspberrypi5', 'debian', '13', 'trixie', 'armhf', 'Raspberry Pi 5 Model B',
'Raspberry Pi reference\n', False),
('raspberrypi5', 'debian', '13', 'trixie', 'arm64', 'Raspberry Pi 4 Model B',
'Raspberry Pi reference\n', False),
('raspberrypi5', 'debian', '13', 'trixie', 'arm64', 'Raspberry Pi 5 Model B', '', False),
)
for name, os_id, version, codename, arch, model, marker, accepted in cases:
with self.subTest(name=name, os_id=os_id, version=version, arch=arch, model=model), \
tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
facts = {'/etc/os-release': f'ID={os_id}\nVERSION_ID="{version}"\nVERSION_CODENAME={codename}\n',
'/proc/device-tree/model': model, '/etc/rpi-issue': marker}
for path, content in facts.items():
mapped = root / path.lstrip('/')
mapped.parent.mkdir(parents=True, exist_ok=True)
mapped.write_text(content)
profile = get_profile(name)
def read(path, root=root):
return (root / path.lstrip('/')).read_text()
if accepted:
profile.validate(lambda arch=arch: arch, read)
else:
with self.assertRaises(ProvisionError):
profile.validate(lambda arch=arch: arch, read)
script = render_shell_profiles()
for path in facts:
script = script.replace(path, shlex.quote(str(root / path.lstrip('/'))))
script += '\ndpkg() { printf %s ' + shlex.quote(arch) + '; }\n'
script += 'apex_check_profile ' + shlex.quote(name)
result = subprocess.run(['sh', '-c', script], capture_output=True, timeout=30)
self.assertEqual(result.returncode == 0, accepted, result.stderr)
def test_checked_in_seed_guard_matches_profile_declarations(self):
guard = Path(__file__).parents[1] / 'provisioning/profiles.sh'
self.assertEqual(guard.read_text(), render_shell_profiles())
@@ -78,3 +78,22 @@ class StateTests(unittest.TestCase):
with self.assertRaises(ProvisionError):
record.stage('next', lambda: called.append(True), beat)
self.assertEqual(called, [])
def test_policy_is_persisted_and_changed_policy_cannot_resume(self):
path = self.root / 'journal'
policy = {'profile': 'raspberrypi5', 'zfs_mode': 'auto', 'zfs_enabled': False}
Journal(path, {}, 'rev', 'digest', policy=policy).save()
self.assertEqual(json.loads(path.read_text())['policy'], policy)
Journal(path, {}, 'rev', 'digest', policy=dict(policy))
for change in ({'profile': 'debian13-amd64'}, {'zfs_mode': 'disabled'}, {'zfs_enabled': True}):
with self.subTest(change=change), self.assertRaisesRegex(ProvisionError, 'policy'):
Journal(path, {}, 'rev', 'digest', policy={**policy, **change})
def test_legacy_journal_only_accepts_existing_debian_zfs_policy(self):
path = self.root / 'journal'
Journal(path, {}, 'rev', 'digest').save()
policy = {'profile': 'debian13-amd64', 'zfs_mode': 'required', 'zfs_enabled': True}
Journal(path, {}, 'rev', 'digest', policy=policy).save()
self.assertEqual(json.loads(path.read_text())['policy'], policy)
with self.assertRaisesRegex(ProvisionError, 'policy'):
Journal(path, {}, 'rev', 'digest', policy={**policy, 'zfs_mode': 'auto'})
@@ -365,7 +365,7 @@ class TestStorage(unittest.TestCase):
def test_swap_skip_reports_low_space_and_unsupported_filesystems(self):
self.set_active_swaps([])
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)):
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_blocks=8388608, f_bavail=1, f_frsize=4096)):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertIn('insufficient', result['swap']['reason'])
self.mounts[0]['fstype'] = 'btrfs'
@@ -375,12 +375,12 @@ class TestStorage(unittest.TestCase):
def test_swap_sizes_allocation_and_records_verified_result(self):
self.set_active_swaps([])
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_blocks=32, f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 4 * 1024 ** 3))
self.assertEqual(result['swap'], {'status': 'complete', 'size': 4 * 1024 ** 3})
self.assertEqual(allocate.call_args.args[1:], (0, 1024 ** 3))
self.assertEqual(result['swap'], {'status': 'complete', 'size': 1024 ** 3})
persist.assert_called_once()
def test_existing_active_swap_preserved_without_cli_inspection(self):
@@ -401,7 +401,7 @@ class TestStorage(unittest.TestCase):
def test_unobserved_swap_activation_is_not_persisted_or_reported_complete(self):
self.set_active_swaps([])
self.activate_swap = False
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_blocks=32, f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True), \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
@@ -418,15 +418,45 @@ class TestStorage(unittest.TestCase):
self.assertIn('swap inventory', result['swap']['reason'])
self.assertFalse(self.swap.exists())
def test_small_memory_swap_is_two_gib(self):
def test_small_memory_limits_swap_even_on_spacious_filesystem(self):
self.set_active_swaps([])
self.meminfo.write_text('MemTotal: 4194304 kB\n')
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_blocks=512, f_bavail=128, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap'):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 2 * 1024 ** 3))
self.assertEqual(result['swap']['size'], 2 * 1024 ** 3)
self.assertEqual(allocate.call_args.args[1:], (0, 512 * 1024 ** 2))
self.assertEqual(result['swap']['size'], 512 * 1024 ** 2)
def test_swap_budget_caps_by_ram_capacity_and_available_space(self):
cases = (
(32, 1024, 128, 4096),
(64, 512, 128, 4096),
(12, 32, 16, 1024),
(8, 32, 16, 1024),
(8, 32, 3.75, 512),
(8, 32, 3.4, 0),
(8, 8, 2.25, 256),
(1, 32, 16, 0),
)
self.set_active_swaps([])
for ram, capacity, free, expected_mib in cases:
with self.subTest(ram=ram, capacity=capacity, free=free):
self.swap.unlink(missing_ok=True)
self.meminfo.write_text(f'MemTotal: {ram * 1024 ** 2} kB\n')
space = SimpleNamespace(f_blocks=capacity * 1024, f_bavail=int(free * 1024),
f_frsize=1024 ** 2)
with patch.object(storage.os, 'statvfs', return_value=space), \
patch.object(storage.os, 'posix_fallocate', create=True) as allocate, \
patch.object(storage, '_persist_swap'):
result = reconcile_storage(enrollment(), self.state, self.save)['swap']
if expected_mib:
self.assertEqual(result, {'status': 'complete', 'size': expected_mib * 1024 ** 2})
self.assertEqual(allocate.call_args.args[1:], (0, expected_mib * 1024 ** 2))
else:
self.assertEqual(result['status'], 'skipped')
allocate.assert_not_called()
self.set_active_swaps([])
def test_fstab_preserves_existing_content_and_does_not_duplicate_swap(self):
actual_lstat = Path.lstat
@@ -81,7 +81,33 @@ class TargetTests(unittest.TestCase):
def test_preflight_rejects_wrong_os_before_any_enrollment(self):
with patch.object(target.os, 'geteuid', return_value=0), \
patch.object(target.platform, 'machine', return_value='x86_64'), \
patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="24.04"\n'):
with self.assertRaisesRegex(ProvisionError, 'Debian 13'):
target.preflight()
def test_enrollment_records_pi_policy_and_changed_policy_stops_before_baseline(self):
from provisioning.contract import normalize_user_data
from test_provision_contract import add_env
from provisioning import baseline
state = self.root / 'state'
original_stat = Path.stat
def root_owned(path, *args, **kwargs):
fields = list(original_stat(path, *args, **kwargs))
fields[4] = 0
return os.stat_result(fields)
spec = normalize_user_data(example())
with patch.object(target, 'STATE', state), patch.object(target, 'preflight'), \
patch.object(target, 'validate_keys'), \
patch.object(Path, 'stat', autospec=True, side_effect=root_owned), \
patch.object(baseline, 'apply_baseline', side_effect=RuntimeError('stop after baseline')) as apply:
with self.assertRaisesRegex(RuntimeError, 'stop after baseline'):
target.enroll(spec, 'rev', 'digest', profile='raspberrypi5')
policy = json.loads((state / 'enrollment.json').read_text())['policy']
self.assertEqual(policy, {'profile': 'raspberrypi5', 'zfs_mode': 'auto', 'zfs_enabled': False})
self.assertEqual(apply.call_args.args[0].name, 'raspberrypi5')
self.assertIs(apply.call_args.args[1], spec)
apply.reset_mock()
changed = normalize_user_data(add_env(example(), 'APEX_ZFS_MODE=disabled\n'))
with self.assertRaisesRegex(ProvisionError, 'policy'):
target.enroll(changed, 'rev', 'digest', profile='raspberrypi5')
apply.assert_not_called()
@@ -28,3 +28,27 @@ class UserDataTests(unittest.TestCase):
def test_yaml_boolean_mapping_key_rejected(self):
with self.assertRaisesRegex(ContractError, 'unique strings'):
decode_user_data(b'true: value\n')
def test_bootstrap_decoder_is_explicit_and_rejects_unsafe_input(self):
from provisioning.userdata import decode_bootstrap_data
import yaml
document = {
'hostname': 'node1.example.com',
'users': [{'name': 'adam', 'shell': '/bin/bash', 'groups': ['sudo'],
'sudo': 'ALL=(ALL) NOPASSWD:ALL',
'ssh_authorized_keys': ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA']}],
'apex_bootstrap': {'interface': 1, 'role': 'vm'},
'runcmd': ['/usr/local/bin/apex-bootstrap'],
}
spec = decode_bootstrap_data(yaml.safe_dump(document).encode())
self.assertEqual(spec.hostname, 'node1.example.com')
self.assertEqual(spec.role, 'vm')
with self.assertRaises(ContractError):
decode_user_data(yaml.safe_dump(document).encode())
for extra in ({'write_files': []}, {'repository': 'SECRET'},
{'apex_bootstrap': {'interface': True, 'role': 'vm'}}):
with self.subTest(extra=extra), self.assertRaises(ContractError):
decode_bootstrap_data(yaml.safe_dump({**document, **extra}).encode())
for content in (b'a: &a [*a]', b'a: 1\na: 2', b'x' * (1024 * 1024 + 1)):
with self.assertRaises(ContractError):
decode_bootstrap_data(content)
+102
View File
@@ -0,0 +1,102 @@
// Shared native Grafana Alloy configuration for the physical host.
// Metrics and logs use the endpoints and credentials supplied through
// APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, and APEX_OBS_BASIC_AUTH_USER/PASS.
// ----------------------------------------------------------------------------
// METRICS
// ----------------------------------------------------------------------------
// Native filesystem views; physical capacity is collected on the Proxmox host.
prometheus.exporter.unix "node" {
enable_collectors = ["textfile"]
textfile {
directory = "/var/lib/alloy/textfile"
}
procfs_path = "/proc"
sysfs_path = "/sys"
rootfs_path = "/"
}
// Container metrics (cadvisor-equivalent) via the docker socket.
// Stamp a static `node` label onto each exporter's targets.
discovery.relabel "node_metrics" {
targets = prometheus.exporter.unix.node.targets
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
// Stamp a stable `instance` (the node's public host) instead of the default,
// which is the Alloy container id/address.
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
}
}
prometheus.scrape "node" {
targets = discovery.relabel.node_metrics.output
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "30s"
}
// The configured Prometheus-compatible endpoint must accept remote writes.
prometheus.remote_write "default" {
endpoint {
url = sys.env("APEX_PROM_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
queue_config {
capacity = 5000
max_shards = 10
min_shards = 1
max_samples_per_send = 1000
batch_send_deadline = "5s"
min_backoff = "100ms"
max_backoff = "10s"
}
}
wal {
truncate_frequency = "30m"
min_keepalive_time = "5m"
max_keepalive_time = "30m"
}
}
// ----------------------------------------------------------------------------
// LOGS
// ----------------------------------------------------------------------------
// systemd journal (host services, ufw, sshd, crowdsec, etc.).
loki.source.journal "systemd" {
path = "/var/log/journal"
max_age = "12h"
labels = {
node = sys.env("APEX_NODE_HOST"),
job = "systemd-journal",
}
forward_to = [loki.write.default.receiver]
}
// All docker containers' stdout/stderr.
// Push logs to the central Loki.
loki.write "default" {
endpoint {
url = sys.env("APEX_LOKI_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
}
}
+128
View File
@@ -0,0 +1,128 @@
// Shared native Grafana Alloy configuration for Docker guests.
// Metrics and logs use the endpoints and credentials supplied through
// APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, and APEX_OBS_BASIC_AUTH_USER/PASS.
// ----------------------------------------------------------------------------
// METRICS
// ----------------------------------------------------------------------------
// Native filesystem views; physical capacity is collected on the Proxmox host.
// Container metrics (cadvisor-equivalent) via the docker socket.
prometheus.exporter.cadvisor "containers" {
docker_host = "unix:///var/run/docker.sock"
store_container_labels = false
allowlisted_container_labels = [
"com.docker.compose.project",
"com.docker.compose.service",
]
docker_only = true
enabled_metrics = [
"cpu",
"cpuLoad",
"diskIO",
"memory",
"network",
"oom_event",
]
}
// Stamp a static `node` label onto each exporter's targets.
discovery.relabel "container_metrics" {
targets = prometheus.exporter.cadvisor.containers.targets
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
}
}
prometheus.scrape "containers" {
targets = discovery.relabel.container_metrics.output
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "30s"
}
// The configured Prometheus-compatible endpoint must accept remote writes.
prometheus.remote_write "default" {
endpoint {
url = sys.env("APEX_PROM_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
queue_config {
capacity = 5000
max_shards = 10
min_shards = 1
max_samples_per_send = 1000
batch_send_deadline = "5s"
min_backoff = "100ms"
max_backoff = "10s"
}
}
wal {
truncate_frequency = "30m"
min_keepalive_time = "5m"
max_keepalive_time = "30m"
}
}
// ----------------------------------------------------------------------------
// LOGS
// ----------------------------------------------------------------------------
// systemd journal (host services, ufw, sshd, crowdsec, etc.).
loki.source.journal "systemd" {
path = "/var/log/journal"
max_age = "12h"
labels = {
node = sys.env("APEX_NODE_HOST"),
job = "systemd-journal",
}
forward_to = [loki.write.default.receiver]
}
// All docker containers' stdout/stderr.
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "container_logs" {
targets = discovery.docker.containers.targets
rule {
action = "replace"
source_labels = ["__meta_docker_container_name"]
regex = "/?(.*)"
target_label = "container"
}
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.container_logs.output
relabel_rules = discovery.relabel.container_logs.rules
forward_to = [loki.write.default.receiver]
}
// Push logs to the central Loki.
loki.write "default" {
endpoint {
url = sys.env("APEX_LOKI_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import json
from pathlib import Path
from types import SimpleNamespace
import unittest
import tempfile
from unittest.mock import Mock
from engine import overlay
backup = overlay.load_module(str(Path(__file__).resolve().parents[1] / 'actions/backup/run.py'),
'test_backup_action')
class BackupRecovery(unittest.TestCase):
def test_failed_attempt_preserves_last_success_and_records_new_failure(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'status.json'
backup.write_backup_status(path, 'success', 100)
backup.write_backup_status(path, 'running', 200)
backup.write_backup_status(path, 'failed', 210)
status = json.loads(path.read_text())
self.assertEqual(status['last_success'], 100)
self.assertEqual(status['state'], 'failed')
self.assertEqual(status['updated_at'], 210)
def test_recovers_namespace_owner_before_dependent_without_starting_other_containers(self):
running = {'komf': False, 'komga': False, 'intentionally-stopped': False}
def docker(command, **kwargs):
if command[1] == 'inspect':
return SimpleNamespace(returncode=0, stdout=json.dumps([
{'Id': name, 'State': {'Running': running[name]}} for name in command[2:]]))
name = command[2]
if name == 'komf' and not running['komga']:
return SimpleNamespace(returncode=1)
running[name] = True
return SimpleNamespace(returncode=0)
ctx = SimpleNamespace(sys=SimpleNamespace(run=docker), log=Mock())
backup.restore_containers(ctx, ['komf', 'komga'])
self.assertTrue(running['komf'])
self.assertTrue(running['komga'])
self.assertFalse(running['intentionally-stopped'])
def test_unrecoverable_container_prevents_success(self):
def docker(command, **kwargs):
return SimpleNamespace(returncode=0 if command[1] == 'inspect' else 1,
stdout='[{"Id":"broken","State":{"Running":false}}]')
ctx = SimpleNamespace(sys=SimpleNamespace(run=docker), log=Mock())
with self.assertRaisesRegex(RuntimeError, 'broken'):
backup.restore_containers(ctx, ['broken'])
+52
View File
@@ -0,0 +1,52 @@
"""Native Alloy opt-in and validation boundaries."""
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from engine import overlay
ROOT = Path(__file__).resolve().parents[1]
alloy = overlay.load_module(str(ROOT / 'actions/configure/alloy.py'), 'test_alloy_action')
class NativeAlloy(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.configs = Path(self.temp.name)
self.ctx = SimpleNamespace(paths=SimpleNamespace(configs=str(self.configs), commons=str(ROOT)),
sys=Mock(), log=Mock())
def configure(self, **settings):
(self.configs / 'alloy').mkdir(exist_ok=True)
(self.configs / 'alloy/config.json').write_text(json.dumps(settings))
def test_unconfigured_node_is_untouched(self):
alloy.run(self.ctx, SimpleNamespace(dry_run=False))
self.ctx.sys.sudo.assert_not_called()
def test_invalid_role_or_environment_path_cannot_change_service(self):
for role, path in [('unknown', '/etc/alloy/secrets'), ('host', '/tmp/file\nUser=root')]:
self.configure(role=role, environment_file=path)
with self.assertRaises(ValueError):
alloy.run(self.ctx, SimpleNamespace(dry_run=False))
self.ctx.sys.sudo.assert_not_called()
def test_dry_run_never_installs_or_starts(self):
self.configure(role='guest', environment_file='/etc/alloy/secrets')
alloy.run(self.ctx, SimpleNamespace(dry_run=True))
self.ctx.sys.sudo.assert_not_called()
def test_failed_config_validation_leaves_existing_service_untouched(self):
self.configure(role='host', environment_file='/etc/alloy/secrets')
def execute(command):
if command[0] == 'python3':
raise RuntimeError('invalid Alloy configuration')
self.assertEqual(command, ['test', '-f', '/etc/alloy/secrets'])
self.ctx.sys.sudo.side_effect = execute
with self.assertRaisesRegex(RuntimeError, 'invalid Alloy'):
alloy.run(self.ctx, SimpleNamespace(dry_run=False))
self.ctx.sys.daemon_reload.assert_not_called()