chore: preserve laptop work before checkout migration [skip ci]
This commit is contained in:
1 parent
7ad4473e93
commit
d83bad9d34
25 files changed
+1060
-86
No files matched your search
+47
-2
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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__":
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user