From 18030361c04ba4804cf4584287848f45bcf839e5 Mon Sep 17 00:00:00 2001 From: James Dougan Date: Fri, 3 Jul 2026 21:53:56 +0100 Subject: [PATCH] add more features... --- README.md | 149 ++++++---- scripts/run-agent.sh | 23 +- src/nirj_agent/cli/main.py | 419 ++++++++++++++------------- src/nirj_agent/config/__init__.py | 10 +- src/nirj_agent/config/store.py | 36 +++ src/nirj_agent/services/boot.py | 106 +++++++ src/nirj_agent/services/overlay.py | 56 ++++ src/nirj_agent/services/runner.py | 24 +- src/nirj_agent/services/update.py | 59 ++++ src/nirj_agent/services/wallpaper.py | 22 ++ src/nirj_agent/storage/json.py | 33 +++ src/nirj_agent/storage/paths.py | 37 ++- src/nirj_agent/update/__init__.py | 4 + src/nirj_agent/update/models.py | 16 + src/nirj_agent/update/store.py | 27 ++ tests/services/test_boot.py | 74 +++++ tests/services/test_overlay.py | 39 +++ tests/services/test_runner.py | 62 +--- tests/services/test_update.py | 36 +++ tests/test_cli.py | 5 +- tests/test_cli_apply.py | 2 +- tests/test_cli_manifest.py | 2 +- tests/test_cli_plan.py | 2 +- tests/test_config.py | 33 ++- tests/test_paths.py | 18 +- tests/test_update_state.py | 17 ++ 26 files changed, 927 insertions(+), 384 deletions(-) create mode 100644 src/nirj_agent/services/boot.py create mode 100644 src/nirj_agent/services/overlay.py create mode 100644 src/nirj_agent/services/update.py create mode 100644 src/nirj_agent/services/wallpaper.py create mode 100644 src/nirj_agent/storage/json.py create mode 100644 src/nirj_agent/update/__init__.py create mode 100644 src/nirj_agent/update/models.py create mode 100644 src/nirj_agent/update/store.py create mode 100644 tests/services/test_boot.py create mode 100644 tests/services/test_overlay.py create mode 100644 tests/services/test_update.py create mode 100644 tests/test_update_state.py diff --git a/README.md b/README.md index f433c43..9c80330 100644 --- a/README.md +++ b/README.md @@ -2,87 +2,116 @@ Device management agent for Northern Ireland Raspberry Jam devices. -## Development usage +## Persistent layout + +Production state lives on a persistent filesystem mounted at `/data`, outside +the root OverlayFS: + +```text +/data/nirj/ +├── agent-repo/ +├── agent-venv/ +├── run.sh +├── config/config.yaml +├── state/ +│ ├── state.yaml +│ ├── update.json +│ ├── current-manifest.json +│ └── target-manifest.json +├── logs/ +└── cache/ +``` -Create a virtual environment and install the package in editable mode: +`install.sh` refuses to install unless `/data` is already a mount point. Disk +partitioning is intentionally a separate image/provisioning responsibility; +guessing a block device in a `curl | bash` installer risks destroying data. -```bash -python3 -m venv .venv -source .venv/bin/activate -python -m pip install -e '.[dev]' -pytest -q -``` +## Bootstrap -Use `--root` to run the CLI against an isolated filesystem tree instead of -reading or writing the host's `/etc`, `/var`, `/boot`, and `/usr` paths: +The production copy of `install.sh` belongs in `nirj-infra`; the copy in this +repository is the implementation template. Run it as root with the device +identity: ```bash -mkdir -p .sandbox/etc/nirj-agent -mkdir -p .sandbox/var/lib/nirj-agent -nirj-agent --root .sandbox status -nirj-agent --root .sandbox get-config -nirj-agent --root .sandbox manifest refresh -nirj-agent --root .sandbox plan +curl -fsSL \ + https://raw.githubusercontent.com/NIRaspberryJam/nirj-infra/main/install.sh \ + | sudo bash -s -- --asset-id PI5-001 --device-type pi5 ``` -The sandbox layout mirrors the production filesystem. For example, its -configuration belongs at `.sandbox/etc/nirj-agent/config.yaml` and its state -at `.sandbox/var/lib/nirj-agent/state.yaml`. +The installer installs prerequisites, clones `nirj-agent`, creates its venv, +writes the stable `/data/nirj/run.sh`, creates the global CLI symlink, and +enables the root-owned systemd service. Existing device configuration is +preserved. + +At each service start, `run.sh` fast-forwards the agent checkout, updates the +venv, runs `nirj-agent boot-prep`, then starts the idle long-running process +with `nirj-agent up`. Exit code 194 means boot preparation requested a reboot; +the runner exits cleanly instead of starting the daemon during shutdown. + +## Update and OverlayFS flow -`manifest refresh` downloads and validates the configured manifest before -atomically caching it. `plan` reads that cached manifest and reports package -changes without installing, removing, or changing any state. +`boot-prep` downloads and validates the target manifest. If it differs from +the current applied manifest while `/` is an overlay, the agent records +`pending_update`, changes the generated wallpaper state, disables OverlayFS, +syncs, and reboots. On the writable boot it records `applying_update`, applies +the manifest, promotes the target manifest to current, restores OverlayFS, and +reboots. Failures persist as `failed` with an error in `update.json`. -## Production usage +Overlay activity is determined from `findmnt -n -o FSTYPE /`; configuration +changes use Raspberry Pi OS's `raspi-config nonint` interface and always +require a reboot. -Run the installer as root with the device's asset ID and type: +Useful commands: ```bash -curl -fsSL \ - https://raw.githubusercontent.com/NIRaspberryJam/nirj-agent/main/install.sh \ - | sudo bash -s -- --asset-id PI5-001 --device-type pi5 +nirj-agent status +nirj-agent update check +sudo nirj-agent update apply +sudo nirj-agent boot-prep + +nirj-agent overlay status +sudo nirj-agent overlay enable +sudo nirj-agent overlay disable + +nirj-agent config get device.asset_id +sudo nirj-agent config set overlay.enabled true ``` -Valid device types for this systemd-based installer are `pi5` and `lpt-lx`. -The installer clones the repository into `/opt/nirj-agent/source`, creates a -virtual environment at `/opt/nirj-agent/venv`, creates the initial -configuration, and enables and starts `nirj-agent.service`. Existing -configuration is preserved when the installer is run again. +`status`, `update check`, and `config get` do not mutate persistent state. +System changes and configuration writes require root. `update check` downloads +and validates in memory; root-owned boot/update commands persist the target. -At every service start, `scripts/run-agent.sh` fast-forwards the checkout, -reinstalls the package, and runs `nirj-agent up`. The agent then refreshes and -applies the configured manifest before remaining active. +The current schema applies APT package state and carries OverlayFS/background +flags. The state machine is structured so files, services, and explicit tasks +can be added as typed manifest providers rather than executing arbitrary data +from a public URL. -Inspect the service with: +## Development ```bash -sudo systemctl status nirj-agent.service -sudo journalctl -u nirj-agent.service -f +python3 -m venv venv +source venv/bin/activate +python -m pip install -e '.[dev]' +python -m pytest -q ``` -Production commands use `/opt/nirj-agent/venv` and omit `--root`: +Read-only and setup commands can use `--root` to target a sandbox that mirrors +the production `/data/nirj` layout: ```bash -sudo /opt/nirj-agent/venv/bin/nirj-agent get-config -/opt/nirj-agent/venv/bin/nirj-agent status -sudo /opt/nirj-agent/venv/bin/nirj-agent manifest refresh -sudo /opt/nirj-agent/venv/bin/nirj-agent plan -sudo /opt/nirj-agent/venv/bin/nirj-agent apply -sudo /opt/nirj-agent/venv/bin/nirj-agent up +nirj-agent --root .sandbox setup --asset-id TEST-001 --device-type pi5 +nirj-agent --root .sandbox status +nirj-agent --root .sandbox config get device.asset_id +nirj-agent --root .sandbox manifest refresh +nirj-agent --root .sandbox plan ``` -`apply` requires root and uses the previously cached manifest. It runs -`apt-get update` only when packages need to be installed, installs missing -packages, removes only obsolete packages previously managed by the agent, and -persists state after every package operation succeeds. It never runs -`autoremove`. The command deliberately rejects `--root` because that option -cannot sandbox apt operations. - -`up` is the long-running production command. It requires root, refreshes the -configured manifest, applies it, and then remains running until it receives a -shutdown signal. It deliberately rejects `--root` for the same reason as -`apply`. - -The production configuration is read from `/etc/nirj-agent/config.yaml` and -state is read from `/var/lib/nirj-agent/state.yaml`. A systemd unit will be -added with the production installer. +Commands that invoke APT, OverlayFS, reboot, or production configuration writes +reject `--root` because those effects cannot be filesystem-sandboxed. + +Inspect production operation with: + +```bash +sudo systemctl status nirj-agent.service +sudo journalctl -u nirj-agent.service -f +``` diff --git a/scripts/run-agent.sh b/scripts/run-agent.sh index 099c942..1ba273f 100755 --- a/scripts/run-agent.sh +++ b/scripts/run-agent.sh @@ -1,18 +1,33 @@ #!/usr/bin/env bash set -Eeuo pipefail -readonly INSTALL_DIR="${NIRJ_AGENT_INSTALL_DIR:-/opt/nirj-agent}" -readonly REPO_DIR="${INSTALL_DIR}/source" -readonly VENV_DIR="${INSTALL_DIR}/venv" +readonly INSTALL_DIR="${NIRJ_AGENT_INSTALL_DIR:-/data/nirj}" +readonly REPO_DIR="${INSTALL_DIR}/agent-repo" +readonly VENV_DIR="${INSTALL_DIR}/agent-venv" readonly BRANCH="${NIRJ_AGENT_BRANCH:-main}" export GIT_TERMINAL_PROMPT=0 export PIP_DISABLE_PIP_VERSION_CHECK=1 -git -C "${REPO_DIR}" pull --ff-only origin "${BRANCH}" +git -C "${REPO_DIR}" fetch origin "${BRANCH}" +git -C "${REPO_DIR}" checkout "${BRANCH}" +git -C "${REPO_DIR}" merge --ff-only "origin/${BRANCH}" "${VENV_DIR}/bin/python" -m pip install \ --upgrade \ "${REPO_DIR}" +set +e +"${VENV_DIR}/bin/nirj-agent" boot-prep +boot_prep_status=$? +set -e + +if [[ "${boot_prep_status}" -eq 194 ]]; then + exit 0 +fi + +if [[ "${boot_prep_status}" -ne 0 ]]; then + exit "${boot_prep_status}" +fi + exec "${VENV_DIR}/bin/nirj-agent" up diff --git a/src/nirj_agent/cli/main.py b/src/nirj_agent/cli/main.py index 39c4398..a10a7ee 100644 --- a/src/nirj_agent/cli/main.py +++ b/src/nirj_agent/cli/main.py @@ -2,72 +2,92 @@ import json import os import signal +import sys from dataclasses import asdict from pathlib import Path -import sys from threading import Event from typing import Sequence -from nirj_agent.config import ConfigError, DeviceType, create_config, load_config +import yaml + +from nirj_agent.config import ( + ConfigError, + DeviceType, + create_config, + get_config_value, + load_config, + set_config_value, +) from nirj_agent.manifests.github import GitHubManifestClient, ManifestDownloadError from nirj_agent.manifests.parser import ManifestError from nirj_agent.providers import AptProvider, AptProviderError from nirj_agent.services.apply import ApplyError, apply_manifest +from nirj_agent.services.boot import boot_prep from nirj_agent.services.manifest import refresh_manifest +from nirj_agent.services.overlay import OverlayError, OverlayManager from nirj_agent.services.plan import PlanError, create_plan from nirj_agent.services.runner import run_agent +from nirj_agent.services.update import check_for_update from nirj_agent.state import load_state from nirj_agent.storage.files import FileStoreError +from nirj_agent.storage.json import JsonStoreError from nirj_agent.storage.lock import LockError from nirj_agent.storage.paths import AgentPaths from nirj_agent.storage.yaml import YamlStoreError +EXPECTED_ERRORS = ( + ApplyError, + AptProviderError, + ConfigError, + FileStoreError, + JsonStoreError, + LockError, + ManifestDownloadError, + ManifestError, + OverlayError, + PlanError, + YamlStoreError, +) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="nirj-agent") - parser.add_argument( - "--root", - type=Path, - help="use a sandbox filesystem root instead of system paths", - ) - subcommands = parser.add_subparsers(dest="command", required=True) - - subcommands.add_parser("status") - subcommands.add_parser("get-config") - subcommands.add_parser( - "up", - help="reconcile the device and start the long-running agent", - ) - subcommands.add_parser( - "plan", - help="show package changes without applying them", - ) - subcommands.add_parser( - "apply", - help="apply package changes from the cached manifest", - ) - - setup_parser = subcommands.add_parser( - "setup", - help="create the initial device configuration", - ) - setup_parser.add_argument( - "--device-type", - required=True, - choices=[device_type.value for device_type in DeviceType], - ) - setup_parser.add_argument("--asset-id", required=True) - - manifest_parser = subcommands.add_parser("manifest") - manifest_commands = manifest_parser.add_subparsers( - dest="manifest_command", - required=True, - ) - manifest_commands.add_parser( - "refresh", - help="download, validate and cache the configured manifest", - ) - + parser.add_argument("--root", type=Path, help="use a sandbox filesystem root") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("status") + commands.add_parser("get-config", help=argparse.SUPPRESS) + commands.add_parser("up", help="start the long-running agent") + commands.add_parser("boot-prep", help="perform boot-time update preparation") + commands.add_parser("plan", help="show package changes without applying them") + commands.add_parser("apply", help="apply the cached target manifest") + + setup = commands.add_parser("setup") + setup.add_argument("--device-type", required=True, choices=[v.value for v in DeviceType]) + setup.add_argument("--asset-id", required=True) + + manifest = commands.add_parser("manifest") + manifest_commands = manifest.add_subparsers(dest="manifest_command", required=True) + manifest_commands.add_parser("refresh") + + update = commands.add_parser("update") + update_commands = update.add_subparsers(dest="update_command", required=True) + update_commands.add_parser("check") + update_commands.add_parser("apply") + + overlay = commands.add_parser("overlay") + overlay_commands = overlay.add_subparsers(dest="overlay_command", required=True) + overlay_commands.add_parser("status") + overlay_commands.add_parser("enable") + overlay_commands.add_parser("disable") + + config = commands.add_parser("config") + config_commands = config.add_subparsers(dest="config_command", required=True) + config_get = config_commands.add_parser("get") + config_get.add_argument("key", nargs="?") + config_set = config_commands.add_parser("set") + config_set.add_argument("key") + config_set.add_argument("value") return parser @@ -76,178 +96,159 @@ def main(argv: Sequence[str] | None = None) -> int: args = parser.parse_args(argv) paths = AgentPaths.sandbox(args.root) if args.root else AgentPaths.system() - if args.command == "status": - state = load_state(paths.state) - print(json.dumps(asdict(state), indent=2)) - return 0 if state.ready else 1 - - if args.command == "get-config": - config = load_config(paths.config) - print(json.dumps(asdict(config), indent=2, default=str)) - return 0 - - if args.command == "up": - if args.root is not None: - print( - "Agent startup does not support --root because package " - "application cannot be filesystem-sandboxed", - file=sys.stderr, - ) - return 1 - - if os.geteuid() != 0: - print("Agent startup must run as root", file=sys.stderr) - return 1 - - stop_event = Event() - - def request_shutdown(_signum, _frame) -> None: - stop_event.set() - - previous_sigint = signal.signal(signal.SIGINT, request_shutdown) - previous_sigterm = signal.signal(signal.SIGTERM, request_shutdown) - - try: - run_agent( - paths=paths, - stop_event=stop_event, - manifest_client=GitHubManifestClient(), - package_provider=AptProvider(), - ) - except ( - ApplyError, - AptProviderError, - ConfigError, - FileStoreError, - LockError, - ManifestDownloadError, - ManifestError, - PlanError, - YamlStoreError, - ) as exc: - print(f"Agent startup failed: {exc}", file=sys.stderr) - return 1 - finally: - signal.signal(signal.SIGINT, previous_sigint) - signal.signal(signal.SIGTERM, previous_sigterm) - - return 0 - - if args.command == "plan": - try: - plan = create_plan( + try: + if args.command == "status": + state = load_state(paths.state) + print(json.dumps(asdict(state), indent=2)) + return 0 if state.ready else 1 + + if args.command == "get-config" or ( + args.command == "config" and args.config_command == "get" and args.key is None + ): + print(json.dumps(asdict(load_config(paths.config)), indent=2, default=str)) + return 0 + + if args.command == "config" and args.config_command == "get": + print(json.dumps(get_config_value(args.key, paths.config), default=str)) + return 0 + + if args.command == "config" and args.config_command == "set": + if not _require_root(args.root, "Configuration changes"): + return 1 + value = yaml.safe_load(args.value) + if isinstance(value, (dict, list)): + raise ConfigError("Configuration values must be scalar") + set_config_value(args.key, value, paths.config) + print(json.dumps({"key": args.key, "value": value}, default=str)) + return 0 + + if args.command == "setup": + config = create_config(args.asset_id, DeviceType(args.device_type), paths.config) + print(json.dumps(asdict(config), indent=2, default=str)) + return 0 + + if args.command == "up": + if not _require_root(args.root, "Agent startup"): + return 1 + return _run_forever(paths) + + if args.command == "boot-prep": + if not _require_root(args.root, "Boot preparation"): + return 1 + result = boot_prep( paths=paths, + client=GitHubManifestClient(), package_provider=AptProvider(), + overlay=OverlayManager(), ) - except ( - AptProviderError, - ConfigError, - ManifestError, - PlanError, - YamlStoreError, - ) as exc: - print(f"Package planning failed: {exc}", file=sys.stderr) - return 1 - - print( - json.dumps( - { - "changes_required": plan.changes_required, - "install": plan.install, - "remove": plan.remove, - "unchanged": plan.unchanged, - }, - indent=2, - ) - ) - return 0 - - if args.command == "apply": - if args.root is not None: - print( - "Package application does not support --root because apt " - "cannot be filesystem-sandboxed", - file=sys.stderr, - ) - return 1 - - if os.geteuid() != 0: - print("Package application must run as root", file=sys.stderr) - return 1 - - try: - result = apply_manifest( + print(json.dumps(asdict(result), indent=2)) + return 194 if result.reboot_requested else 0 + + if args.command == "update" and args.update_command == "check": + check = check_for_update(paths=paths, client=GitHubManifestClient()) + print(json.dumps(asdict(check), indent=2)) + return 0 + + if args.command == "update" and args.update_command == "apply": + if not _require_root(args.root, "Update application"): + return 1 + result = boot_prep( paths=paths, + client=GitHubManifestClient(), package_provider=AptProvider(), + overlay=OverlayManager(), ) - except ( - ApplyError, - AptProviderError, - ConfigError, - FileStoreError, - LockError, - ManifestError, - YamlStoreError, - ) as exc: - print(f"Package application failed: {exc}", file=sys.stderr) - return 1 - - print( - json.dumps( - { - "manifest_hash": result.state.manifest_hash, - "last_apply": result.state.last_apply, - "install": result.plan.install, - "remove": result.plan.remove, - "ready": result.state.ready, - }, - indent=2, - ) - ) - return 0 - - if args.command == "setup": - try: - config = create_config( - asset_id=args.asset_id, - device_type=DeviceType(args.device_type), - path=paths.config, - ) - except ConfigError as exc: - parser.error(str(exc)) - - print(json.dumps(asdict(config), indent=2, default=str)) - return 0 - - if args.command == "manifest" and args.manifest_command == "refresh": - try: - config = load_config(paths.config) + print(json.dumps(asdict(result), indent=2)) + return 194 if result.reboot_requested else 0 + + if args.command == "overlay": + manager = OverlayManager() + if args.overlay_command == "status": + print(json.dumps(asdict(manager.status()), indent=2)) + return 0 + if not _require_root(args.root, "Overlay changes"): + return 1 + getattr(manager, args.overlay_command)() + manager.sync_and_reboot() + return 0 + + if args.command == "plan": + plan = create_plan(paths=paths, package_provider=AptProvider()) + print(json.dumps({ + "changes_required": plan.changes_required, + "install": plan.install, + "remove": plan.remove, + "unchanged": plan.unchanged, + }, indent=2)) + return 0 + + if args.command == "apply": + if not _require_root(args.root, "Package application"): + return 1 + result = apply_manifest(paths=paths, package_provider=AptProvider()) + print(json.dumps({ + "manifest_hash": result.state.manifest_hash, + "last_apply": result.state.last_apply, + "install": result.plan.install, + "remove": result.plan.remove, + "ready": result.state.ready, + }, indent=2)) + return 0 + + if args.command == "manifest" and args.manifest_command == "refresh": document = refresh_manifest( - config=config, + config=load_config(paths.config), paths=paths, client=GitHubManifestClient(), ) - except ( - ConfigError, - ManifestDownloadError, - ManifestError, - FileStoreError, - ) as exc: - print(f"Manifest refresh failed: {exc}", file=sys.stderr) - return 1 - - print( - json.dumps( - { - "schema": document.manifest.schema, - "sha256": document.sha256, - "source": document.source_url, - "cache": str(paths.manifest_cache), - "packages": len(document.manifest.apt.packages), - }, - indent=2, - ) - ) - return 0 - + print(json.dumps({ + "schema": document.manifest.schema, + "sha256": document.sha256, + "source": document.source_url, + "cache": str(paths.manifest_cache), + "packages": len(document.manifest.apt.packages), + }, indent=2)) + return 0 + except EXPECTED_ERRORS as exc: + print(f"{_operation_name(args)} failed: {exc}", file=sys.stderr) + return 1 return 2 + + +def _require_root(root: Path | None, operation: str) -> bool: + if root is not None: + print(f"{operation} does not support --root", file=sys.stderr) + return False + if os.geteuid() != 0: + print(f"{operation} must run as root", file=sys.stderr) + return False + return True + + +def _run_forever(paths: AgentPaths) -> int: + stop_event = Event() + def request_shutdown(_signum, _frame) -> None: + stop_event.set() + previous_sigint = signal.signal(signal.SIGINT, request_shutdown) + previous_sigterm = signal.signal(signal.SIGTERM, request_shutdown) + try: + run_agent(paths=paths, stop_event=stop_event) + finally: + signal.signal(signal.SIGINT, previous_sigint) + signal.signal(signal.SIGTERM, previous_sigterm) + return 0 + + +def _operation_name(args) -> str: + names = { + "apply": "Package application", + "plan": "Package planning", + "manifest": "Manifest refresh", + "boot-prep": "Boot preparation", + "up": "Agent startup", + "update": "Update", + "overlay": "Overlay operation", + "config": "Configuration operation", + "setup": "Setup", + } + return names.get(args.command, args.command.capitalize()) diff --git a/src/nirj_agent/config/__init__.py b/src/nirj_agent/config/__init__.py index 963a30d..47098a7 100644 --- a/src/nirj_agent/config/__init__.py +++ b/src/nirj_agent/config/__init__.py @@ -1,5 +1,11 @@ from .models import AgentConfig, DeviceConfig, DeviceType, ManifestSource -from .store import ConfigError, create_config, load_config +from .store import ( + ConfigError, + create_config, + get_config_value, + load_config, + set_config_value, +) __all__ = [ "AgentConfig", @@ -8,5 +14,7 @@ "DeviceType", "ManifestSource", "create_config", + "get_config_value", "load_config", + "set_config_value", ] diff --git a/src/nirj_agent/config/store.py b/src/nirj_agent/config/store.py index d367ffc..4a8f223 100644 --- a/src/nirj_agent/config/store.py +++ b/src/nirj_agent/config/store.py @@ -13,6 +13,10 @@ class ConfigError(ValueError): def load_config(path: Path = CONFIG_PATH) -> AgentConfig: data = read_yaml(path) + return config_from_mapping(data, path) + + +def config_from_mapping(data: dict, path: Path) -> AgentConfig: try: device = data["device"] @@ -41,6 +45,38 @@ def load_config(path: Path = CONFIG_PATH) -> AgentConfig: raise ConfigError(f"Invalid configuration in {path}: {exc}") from exc +def get_config_value(key: str, path: Path = CONFIG_PATH): + value = read_yaml(path) + for part in _key_parts(key): + if not isinstance(value, dict) or part not in value: + raise ConfigError(f"Unknown configuration key: {key}") + value = value[part] + return value + + +def set_config_value(key: str, value, path: Path = CONFIG_PATH) -> AgentConfig: + data = read_yaml(path) + target = data + parts = _key_parts(key) + for part in parts[:-1]: + if not isinstance(target, dict) or part not in target: + raise ConfigError(f"Unknown configuration key: {key}") + target = target[part] + if not isinstance(target, dict) or parts[-1] not in target: + raise ConfigError(f"Unknown configuration key: {key}") + target[parts[-1]] = value + config = config_from_mapping(data, path) + write_yaml(path, data) + return config + + +def _key_parts(key: str) -> list[str]: + parts = key.split(".") + if not key or any(not part for part in parts): + raise ConfigError(f"Invalid configuration key: {key}") + return parts + + def create_config( asset_id: str, device_type: DeviceType, diff --git a/src/nirj_agent/services/boot.py b/src/nirj_agent/services/boot.py new file mode 100644 index 0000000..6101110 --- /dev/null +++ b/src/nirj_agent/services/boot.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass, replace + +from nirj_agent.config import load_config +from nirj_agent.manifests.github import GitHubManifestClient +from nirj_agent.providers import AptProvider +from nirj_agent.state import load_state, save_state +from nirj_agent.storage.paths import AgentPaths +from nirj_agent.update import ( + UpdatePhase, + UpdateState, + load_update_state, + save_update_state, +) + +from .overlay import OverlayManager +from .update import apply_target, check_for_update +from .wallpaper import set_wallpaper_state + + +@dataclass(frozen=True) +class BootPrepResult: + action: str + reboot_requested: bool + + +def boot_prep( + paths: AgentPaths, + client: GitHubManifestClient, + package_provider: AptProvider, + overlay: OverlayManager, +) -> BootPrepResult: + target_hash = None + try: + config = load_config(paths.config) + update = load_update_state(paths.update_state) + target_hash = update.target_hash + overlay_status = overlay.status() + + if update.state is UpdatePhase.PENDING: + if overlay_status.active: + overlay.disable() + overlay.sync_and_reboot() + return BootPrepResult("waiting_for_writable_boot", True) + return _apply_and_restore(paths, config.overlay_enabled, package_provider, overlay) + + check = check_for_update(paths, client, persist_target=True) + if check.update_available: + save_update_state( + UpdateState(UpdatePhase.PENDING, check.target_hash), + paths.update_state, + ) + target_hash = check.target_hash + set_wallpaper_state(paths, "updating") + if overlay_status.active: + overlay.disable() + overlay.sync_and_reboot() + return BootPrepResult("update_pending", True) + return _apply_and_restore(paths, config.overlay_enabled, package_provider, overlay) + + set_wallpaper_state(paths, "ready") + state = replace( + load_state(paths.state), + manifest_hash=check.current_hash, + overlay_enabled=overlay_status.active, + ready=True, + ) + save_state(state, paths.state) + save_update_state(UpdateState(), paths.update_state) + + if config.overlay_enabled and not overlay_status.active: + overlay.enable() + overlay.sync_and_reboot() + return BootPrepResult("enabling_overlay", True) + if not config.overlay_enabled and overlay_status.active: + overlay.disable() + overlay.sync_and_reboot() + return BootPrepResult("disabling_overlay", True) + return BootPrepResult("ready", False) + except Exception as exc: + save_update_state( + UpdateState(UpdatePhase.FAILED, target_hash, str(exc)), + paths.update_state, + ) + set_wallpaper_state(paths, "failed") + raise + + +def _apply_and_restore( + paths: AgentPaths, + overlay_desired: bool, + package_provider: AptProvider, + overlay: OverlayManager, +) -> BootPrepResult: + pending = load_update_state(paths.update_state) + save_update_state( + replace(pending, state=UpdatePhase.APPLYING, error=None), + paths.update_state, + ) + apply_target(paths, package_provider) + save_update_state(UpdateState(), paths.update_state) + set_wallpaper_state(paths, "ready") + if overlay_desired: + overlay.enable() + overlay.sync_and_reboot() + return BootPrepResult("update_applied", True) + return BootPrepResult("update_applied", False) diff --git a/src/nirj_agent/services/overlay.py b/src/nirj_agent/services/overlay.py new file mode 100644 index 0000000..c59deeb --- /dev/null +++ b/src/nirj_agent/services/overlay.py @@ -0,0 +1,56 @@ +import subprocess +from dataclasses import dataclass +from typing import Protocol + + +class OverlayError(RuntimeError): + pass + + +class CommandRunner(Protocol): + def __call__(self, args: list[str], **kwargs) -> subprocess.CompletedProcess[str]: ... + + +@dataclass(frozen=True) +class OverlayStatus: + active: bool + configured: bool | None + + +class OverlayManager: + def __init__(self, runner: CommandRunner = subprocess.run) -> None: + self.runner = runner + + def status(self) -> OverlayStatus: + filesystem = self._run(["findmnt", "-n", "-o", "FSTYPE", "/"]) + configured_result = self._run( + ["raspi-config", "nonint", "get_overlay_now"], + check=False, + ) + configured = configured_result.returncode == 0 + return OverlayStatus( + active=filesystem.stdout.strip() == "overlay", + configured=configured, + ) + + def enable(self) -> None: + self._run(["raspi-config", "nonint", "enable_overlayfs"]) + + def disable(self) -> None: + self._run(["raspi-config", "nonint", "disable_overlayfs"]) + + def sync_and_reboot(self) -> None: + self._run(["sync"]) + self._run(["systemctl", "reboot"]) + + def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + try: + return self.runner( + args, + check=check, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = getattr(exc, "stderr", None) or str(exc) + raise OverlayError(f"Command {' '.join(args)} failed: {detail.strip()}") from exc diff --git a/src/nirj_agent/services/runner.py b/src/nirj_agent/services/runner.py index f99662f..9565334 100644 --- a/src/nirj_agent/services/runner.py +++ b/src/nirj_agent/services/runner.py @@ -1,31 +1,11 @@ from threading import Event -from nirj_agent.config import load_config -from nirj_agent.manifests.github import GitHubManifestClient -from nirj_agent.providers import AptProvider -from nirj_agent.services.apply import ApplyResult, apply_manifest -from nirj_agent.services.manifest import refresh_manifest from nirj_agent.storage.paths import AgentPaths def run_agent( paths: AgentPaths, stop_event: Event, - manifest_client: GitHubManifestClient, - package_provider: AptProvider, -) -> ApplyResult: - config = load_config(paths.config) - refresh_manifest( - config=config, - paths=paths, - client=manifest_client, - ) - result = apply_manifest( - paths=paths, - package_provider=package_provider, - ) - - print("Initial reconciliation complete; agent is running", flush=True) +) -> None: + print("Agent is running", flush=True) stop_event.wait() - - return result diff --git a/src/nirj_agent/services/update.py b/src/nirj_agent/services/update.py new file mode 100644 index 0000000..2680100 --- /dev/null +++ b/src/nirj_agent/services/update.py @@ -0,0 +1,59 @@ +import hashlib +from dataclasses import dataclass, replace + +from nirj_agent.config import load_config +from nirj_agent.manifests.github import GitHubManifestClient +from nirj_agent.manifests.parser import parse_manifest +from nirj_agent.providers import AptProvider +from nirj_agent.services.apply import ApplyResult, apply_manifest +from nirj_agent.services.manifest import refresh_manifest +from nirj_agent.state import load_state, save_state +from nirj_agent.storage.files import read_bytes, write_bytes +from nirj_agent.storage.paths import AgentPaths + + +@dataclass(frozen=True) +class UpdateCheck: + update_available: bool + current_hash: str | None + target_hash: str + source: str + + +def check_for_update( + paths: AgentPaths, + client: GitHubManifestClient, + persist_target: bool = False, +) -> UpdateCheck: + config = load_config(paths.config) + if persist_target: + document = refresh_manifest(config, paths, client) + target_hash = document.sha256 + source = document.source_url + else: + source, content = client.fetch(config.manifest) + parse_manifest(content, source) + target_hash = hashlib.sha256(content).hexdigest() + + current_hash = None + if paths.current_manifest.exists(): + current_hash = hashlib.sha256(read_bytes(paths.current_manifest)).hexdigest() + + return UpdateCheck( + update_available=current_hash != target_hash, + current_hash=current_hash, + target_hash=target_hash, + source=source, + ) + + +def apply_target( + paths: AgentPaths, + package_provider: AptProvider, +) -> ApplyResult: + result = apply_manifest(paths, package_provider) + content = read_bytes(paths.target_manifest) + write_bytes(paths.current_manifest, content) + ready_state = replace(result.state, ready=True) + save_state(ready_state, paths.state) + return ApplyResult(plan=result.plan, state=ready_state) diff --git a/src/nirj_agent/services/wallpaper.py b/src/nirj_agent/services/wallpaper.py new file mode 100644 index 0000000..d15f6f1 --- /dev/null +++ b/src/nirj_agent/services/wallpaper.py @@ -0,0 +1,22 @@ +from html import escape + +from nirj_agent.storage.files import write_bytes +from nirj_agent.storage.paths import AgentPaths + + +WALLPAPER_TEXT = { + "ready": "Ready", + "updating": "Updating - Do Not Power Off", + "failed": "Update Failed - See JAMS Dashboard", +} + + +def set_wallpaper_state(paths: AgentPaths, state: str) -> None: + text = WALLPAPER_TEXT[state] + svg = f""" + +{escape(text)} +\n""" + write_bytes(paths.generated_dir / "wallpaper.svg", svg.encode()) + write_bytes(paths.root / "state/wallpaper-state.txt", f"{text}\n".encode()) diff --git a/src/nirj_agent/storage/json.py b/src/nirj_agent/storage/json.py new file mode 100644 index 0000000..0826ea9 --- /dev/null +++ b/src/nirj_agent/storage/json.py @@ -0,0 +1,33 @@ +import json +import os +from pathlib import Path +from typing import Any + + +class JsonStoreError(RuntimeError): + pass + + +def read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise JsonStoreError(f"Unable to read {path}: {exc}") from exc + if not isinstance(value, dict): + raise JsonStoreError(f"{path} must contain a JSON object") + return value + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = path.with_suffix(f"{path.suffix}.tmp") + try: + with temporary_path.open("w", encoding="utf-8") as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary_path.replace(path) + except OSError as exc: + temporary_path.unlink(missing_ok=True) + raise JsonStoreError(f"Unable to write {path}: {exc}") from exc diff --git a/src/nirj_agent/storage/paths.py b/src/nirj_agent/storage/paths.py index b628767..f131dc6 100644 --- a/src/nirj_agent/storage/paths.py +++ b/src/nirj_agent/storage/paths.py @@ -4,9 +4,13 @@ @dataclass(frozen=True) class AgentPaths: + root: Path config: Path state: Path manifest_cache: Path + current_manifest: Path + target_manifest: Path + update_state: Path apply_lock: Path generated_dir: Path maintenance_flag: Path @@ -15,13 +19,15 @@ class AgentPaths: @classmethod def system(cls) -> "AgentPaths": return cls( - config=Path("/etc/nirj-agent/config.yaml"), - state=Path("/var/lib/nirj-agent/state.yaml"), - manifest_cache=Path( - "/var/lib/nirj-agent/manifests/current.yaml" - ), - apply_lock=Path("/run/nirj-agent/apply.lock"), - generated_dir=Path("/var/lib/nirj-agent/generated"), + root=Path("/data/nirj"), + config=Path("/data/nirj/config/config.yaml"), + state=Path("/data/nirj/state/state.yaml"), + manifest_cache=Path("/data/nirj/state/target-manifest.json"), + current_manifest=Path("/data/nirj/state/current-manifest.json"), + target_manifest=Path("/data/nirj/state/target-manifest.json"), + update_state=Path("/data/nirj/state/update.json"), + apply_lock=Path("/data/nirj/state/apply.lock"), + generated_dir=Path("/data/nirj/cache/generated"), maintenance_flag=Path("/boot/firmware/nirj-maintenance"), base_background=Path( "/usr/share/nirj-agent/background-base.png" @@ -30,14 +36,17 @@ def system(cls) -> "AgentPaths": @classmethod def sandbox(cls, root: Path) -> "AgentPaths": + data_root = root / "data/nirj" return cls( - config=root / "etc/nirj-agent/config.yaml", - state=root / "var/lib/nirj-agent/state.yaml", - manifest_cache=( - root / "var/lib/nirj-agent/manifests/current.yaml" - ), - apply_lock=root / "run/nirj-agent/apply.lock", - generated_dir=root / "var/lib/nirj-agent/generated", + root=data_root, + config=data_root / "config/config.yaml", + state=data_root / "state/state.yaml", + manifest_cache=data_root / "state/target-manifest.json", + current_manifest=data_root / "state/current-manifest.json", + target_manifest=data_root / "state/target-manifest.json", + update_state=data_root / "state/update.json", + apply_lock=data_root / "state/apply.lock", + generated_dir=data_root / "cache/generated", maintenance_flag=root / "boot/firmware/nirj-maintenance", base_background=( root / "usr/share/nirj-agent/background-base.png" diff --git a/src/nirj_agent/update/__init__.py b/src/nirj_agent/update/__init__.py new file mode 100644 index 0000000..0e10f36 --- /dev/null +++ b/src/nirj_agent/update/__init__.py @@ -0,0 +1,4 @@ +from .models import UpdatePhase, UpdateState +from .store import load_update_state, save_update_state + +__all__ = ["UpdatePhase", "UpdateState", "load_update_state", "save_update_state"] diff --git a/src/nirj_agent/update/models.py b/src/nirj_agent/update/models.py new file mode 100644 index 0000000..02b82d4 --- /dev/null +++ b/src/nirj_agent/update/models.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from enum import StrEnum + + +class UpdatePhase(StrEnum): + NORMAL = "normal" + PENDING = "pending_update" + APPLYING = "applying_update" + FAILED = "failed" + + +@dataclass(frozen=True) +class UpdateState: + state: UpdatePhase = UpdatePhase.NORMAL + target_hash: str | None = None + error: str | None = None diff --git a/src/nirj_agent/update/store.py b/src/nirj_agent/update/store.py new file mode 100644 index 0000000..7c492e9 --- /dev/null +++ b/src/nirj_agent/update/store.py @@ -0,0 +1,27 @@ +from dataclasses import asdict +from pathlib import Path + +from nirj_agent.storage.json import read_json, write_json +from nirj_agent.storage.paths import SYSTEM_PATHS + +from .models import UpdatePhase, UpdateState + + +def load_update_state(path: Path = SYSTEM_PATHS.update_state) -> UpdateState: + if not path.exists(): + return UpdateState() + data = read_json(path) + return UpdateState( + state=UpdatePhase(str(data.get("state", UpdatePhase.NORMAL))), + target_hash=data.get("target_hash"), + error=data.get("error"), + ) + + +def save_update_state( + state: UpdateState, + path: Path = SYSTEM_PATHS.update_state, +) -> None: + data = asdict(state) + data["state"] = state.state.value + write_json(path, data) diff --git a/tests/services/test_boot.py b/tests/services/test_boot.py new file mode 100644 index 0000000..b4e58db --- /dev/null +++ b/tests/services/test_boot.py @@ -0,0 +1,74 @@ +from types import SimpleNamespace + +from nirj_agent.config import DeviceType, create_config +from nirj_agent.services.boot import boot_prep +from nirj_agent.services.overlay import OverlayStatus +from nirj_agent.storage.paths import AgentPaths +from nirj_agent.update import UpdatePhase, UpdateState, load_update_state, save_update_state + + +MANIFEST = b"schema: 1\napt:\n packages: [git]\n" + + +class Client: + def fetch(self, _source): + return "https://example.test/manifest.yaml", MANIFEST + + +class Packages: + def list_installed(self): + return {"git"} + def update(self): + raise AssertionError("not needed") + def install(self, _packages): + raise AssertionError("not needed") + def remove(self, _packages): + raise AssertionError("not needed") + + +class Overlay: + def __init__(self, active): + self.active = active + self.events = [] + def status(self): + return OverlayStatus(self.active, self.active) + def enable(self): + self.events.append("enable") + def disable(self): + self.events.append("disable") + def sync_and_reboot(self): + self.events.append("reboot") + + +def prepare(tmp_path): + paths = AgentPaths.sandbox(tmp_path) + create_config("PI5-001", DeviceType.PI5, paths.config) + return paths + + +def test_boot_marks_pending_and_disables_active_overlay(tmp_path) -> None: + paths = prepare(tmp_path) + overlay = Overlay(active=True) + + result = boot_prep(paths, Client(), Packages(), overlay) + + assert result.reboot_requested is True + assert overlay.events == ["disable", "reboot"] + assert load_update_state(paths.update_state).state is UpdatePhase.PENDING + assert paths.target_manifest.read_bytes() == MANIFEST + + +def test_writable_boot_applies_target_and_reenables_overlay(tmp_path) -> None: + paths = prepare(tmp_path) + paths.target_manifest.parent.mkdir(parents=True, exist_ok=True) + paths.target_manifest.write_bytes(MANIFEST) + save_update_state(UpdateState(UpdatePhase.PENDING, "target"), paths.update_state) + overlay = Overlay(active=False) + + result = boot_prep(paths, Client(), Packages(), overlay) + + assert result.action == "update_applied" + assert result.reboot_requested is True + assert overlay.events == ["enable", "reboot"] + assert paths.current_manifest.read_bytes() == MANIFEST + assert load_update_state(paths.update_state).state is UpdatePhase.NORMAL diff --git a/tests/services/test_overlay.py b/tests/services/test_overlay.py new file mode 100644 index 0000000..e3511d5 --- /dev/null +++ b/tests/services/test_overlay.py @@ -0,0 +1,39 @@ +from types import SimpleNamespace + +from nirj_agent.services.overlay import OverlayManager + + +def test_overlay_status_uses_findmnt_and_raspi_config() -> None: + calls = [] + + def run(args, **kwargs): + calls.append((args, kwargs)) + output = "overlay\n" if args[0] == "findmnt" else "" + return SimpleNamespace(returncode=0, stdout=output) + + status = OverlayManager(run).status() + + assert status.active is True + assert status.configured is True + assert calls[0][0] == ["findmnt", "-n", "-o", "FSTYPE", "/"] + assert calls[1][0] == ["raspi-config", "nonint", "get_overlay_now"] + + +def test_overlay_transitions_and_reboot_commands() -> None: + calls = [] + + def run(args, **kwargs): + calls.append(args) + return SimpleNamespace(returncode=0, stdout="") + + manager = OverlayManager(run) + manager.disable() + manager.enable() + manager.sync_and_reboot() + + assert calls == [ + ["raspi-config", "nonint", "disable_overlayfs"], + ["raspi-config", "nonint", "enable_overlayfs"], + ["sync"], + ["systemctl", "reboot"], + ] diff --git a/tests/services/test_runner.py b/tests/services/test_runner.py index 427b71b..a1f6418 100644 --- a/tests/services/test_runner.py +++ b/tests/services/test_runner.py @@ -1,69 +1,13 @@ from threading import Event -from nirj_agent.services.apply import ApplyResult -from nirj_agent.services.reconciliation import PackagePlan from nirj_agent.services.runner import run_agent -from nirj_agent.state import AgentState from nirj_agent.storage.paths import AgentPaths -def test_run_agent_refreshes_applies_and_waits( - tmp_path, - monkeypatch, - capsys, -) -> None: +def test_run_agent_is_idle_and_waits(tmp_path, capsys) -> None: paths = AgentPaths.sandbox(tmp_path) stop_event = Event() stop_event.set() - calls = [] - config = object() - client = object() - provider = object() - result = ApplyResult( - plan=PackagePlan( - desired=("git",), - install=("git",), - remove=(), - unchanged=(), - ), - state=AgentState( - manifest_hash="abc123", - last_apply="2026-07-01T00:00:00Z", - packages=("git",), - overlay_enabled=False, - ready=False, - ), - ) - monkeypatch.setattr( - "nirj_agent.services.runner.load_config", - lambda path: calls.append(("load", path)) or config, - ) - - def refresh(**kwargs) -> None: - calls.append(("refresh", kwargs)) - - def apply(**kwargs) -> ApplyResult: - calls.append(("apply", kwargs)) - return result - - monkeypatch.setattr("nirj_agent.services.runner.refresh_manifest", refresh) - monkeypatch.setattr("nirj_agent.services.runner.apply_manifest", apply) - - actual = run_agent( - paths=paths, - stop_event=stop_event, - manifest_client=client, - package_provider=provider, - ) - - assert actual is result - assert calls == [ - ("load", paths.config), - ( - "refresh", - {"config": config, "paths": paths, "client": client}, - ), - ("apply", {"paths": paths, "package_provider": provider}), - ] - assert "Initial reconciliation complete" in capsys.readouterr().out + assert run_agent(paths=paths, stop_event=stop_event) is None + assert "Agent is running" in capsys.readouterr().out diff --git a/tests/services/test_update.py b/tests/services/test_update.py new file mode 100644 index 0000000..518e687 --- /dev/null +++ b/tests/services/test_update.py @@ -0,0 +1,36 @@ +import hashlib + +from nirj_agent.config import DeviceType, create_config +from nirj_agent.services.update import check_for_update +from nirj_agent.storage.paths import AgentPaths + + +MANIFEST = b"schema: 1\napt:\n packages: [git]\n" + + +class Client: + def fetch(self, _source): + return "https://example.test/manifest.yaml", MANIFEST + + +def test_check_compares_without_persisting_target(tmp_path) -> None: + paths = AgentPaths.sandbox(tmp_path) + create_config("PI5-001", DeviceType.PI5, paths.config) + paths.current_manifest.parent.mkdir(parents=True, exist_ok=True) + paths.current_manifest.write_bytes(MANIFEST) + + result = check_for_update(paths, Client()) + + assert result.update_available is False + assert result.current_hash == hashlib.sha256(MANIFEST).hexdigest() + assert not paths.target_manifest.exists() + + +def test_check_can_persist_validated_target(tmp_path) -> None: + paths = AgentPaths.sandbox(tmp_path) + create_config("PI5-001", DeviceType.PI5, paths.config) + + result = check_for_update(paths, Client(), persist_target=True) + + assert result.update_available is True + assert paths.target_manifest.read_bytes() == MANIFEST diff --git a/tests/test_cli.py b/tests/test_cli.py index 07f62f1..5ee8fbe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -69,7 +69,7 @@ def load_test_state(path): result = cli.main(["--root", str(tmp_path), "status"]) assert result == 1 - assert loaded_paths == [tmp_path / "var/lib/nirj-agent/state.yaml"] + assert loaded_paths == [tmp_path / "data/nirj/state/state.yaml"] capsys.readouterr() @@ -103,5 +103,4 @@ def run_test_agent(**kwargs) -> None: assert result == 0 assert received["paths"] == cli.AgentPaths.system() assert isinstance(received["stop_event"], cli.Event) - assert isinstance(received["manifest_client"], cli.GitHubManifestClient) - assert isinstance(received["package_provider"], cli.AptProvider) + assert set(received) == {"paths", "stop_event"} diff --git a/tests/test_cli_apply.py b/tests/test_cli_apply.py index fa23ebf..1153c54 100644 --- a/tests/test_cli_apply.py +++ b/tests/test_cli_apply.py @@ -47,7 +47,7 @@ def test_apply_prints_result(monkeypatch, capsys) -> None: ) def apply_test_manifest(paths, package_provider): - assert paths.config == Path("/etc/nirj-agent/config.yaml") + assert paths.config == Path("/data/nirj/config/config.yaml") assert package_provider is provider return SimpleNamespace(plan=plan, state=state) diff --git a/tests/test_cli_manifest.py b/tests/test_cli_manifest.py index ede1ea3..198c532 100644 --- a/tests/test_cli_manifest.py +++ b/tests/test_cli_manifest.py @@ -30,7 +30,7 @@ def test_manifest_refresh_prints_summary(tmp_path: Path, monkeypatch, capsys) -> assert result == 0 assert '"sha256": "abc123"' in output.out assert '"packages": 2' in output.out - assert str(tmp_path / "var/lib/nirj-agent/manifests/current.yaml") in output.out + assert str(tmp_path / "data/nirj/state/target-manifest.json") in output.out assert output.err == "" diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index 343bdd8..1e934aa 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -14,7 +14,7 @@ def test_plan_prints_package_changes(tmp_path: Path, monkeypatch, capsys) -> Non def create_test_plan(paths, package_provider): assert paths.manifest_cache == ( - tmp_path / "var/lib/nirj-agent/manifests/current.yaml" + tmp_path / "data/nirj/state/target-manifest.json" ) assert package_provider is provider return PackagePlan( diff --git a/tests/test_config.py b/tests/test_config.py index 817479a..d650281 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,14 @@ import pytest import yaml -from nirj_agent.config import DeviceType, ConfigError, create_config, load_config +from nirj_agent.config import ( + DeviceType, + ConfigError, + create_config, + get_config_value, + load_config, + set_config_value, +) cli = import_module("nirj_agent.cli.main") @@ -128,8 +135,30 @@ def test_setup_creates_sandbox_config(tmp_path: Path, capsys) -> None: "pi5", ]) - config = load_config(tmp_path / "etc/nirj-agent/config.yaml") + config = load_config(tmp_path / "data/nirj/config/config.yaml") assert result == 0 assert config.device.asset_id == "PI5-001" assert config.device.type is DeviceType.PI5 + + +def test_get_and_set_config_value(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + create_config("PI5-001", DeviceType.PI5, path) + + assert get_config_value("overlay.enabled", path) is True + updated = set_config_value("overlay.enabled", False, path) + + assert updated.overlay_enabled is False + assert get_config_value("overlay.enabled", path) is False + + +def test_set_config_value_validates_before_writing(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + create_config("PI5-001", DeviceType.PI5, path) + original = path.read_bytes() + + with pytest.raises(ConfigError): + set_config_value("device.type", "toaster", path) + + assert path.read_bytes() == original diff --git a/tests/test_paths.py b/tests/test_paths.py index 2a00540..a12cbdb 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -6,9 +6,13 @@ def test_system_paths_use_expected_locations() -> None: paths = AgentPaths.system() - assert paths.config == Path("/etc/nirj-agent/config.yaml") - assert paths.state == Path("/var/lib/nirj-agent/state.yaml") - assert paths.apply_lock == Path("/run/nirj-agent/apply.lock") + assert paths.root == Path("/data/nirj") + assert paths.config == Path("/data/nirj/config/config.yaml") + assert paths.state == Path("/data/nirj/state/state.yaml") + assert paths.current_manifest == Path("/data/nirj/state/current-manifest.json") + assert paths.target_manifest == Path("/data/nirj/state/target-manifest.json") + assert paths.update_state == Path("/data/nirj/state/update.json") + assert paths.apply_lock == Path("/data/nirj/state/apply.lock") assert paths.maintenance_flag == Path( "/boot/firmware/nirj-maintenance" ) @@ -17,10 +21,10 @@ def test_system_paths_use_expected_locations() -> None: def test_sandbox_paths_stay_below_root(tmp_path: Path) -> None: paths = AgentPaths.sandbox(tmp_path) - assert paths.config == tmp_path / "etc/nirj-agent/config.yaml" - assert paths.state == tmp_path / "var/lib/nirj-agent/state.yaml" - assert paths.apply_lock == tmp_path / "run/nirj-agent/apply.lock" - assert paths.generated_dir == tmp_path / "var/lib/nirj-agent/generated" + assert paths.config == tmp_path / "data/nirj/config/config.yaml" + assert paths.state == tmp_path / "data/nirj/state/state.yaml" + assert paths.apply_lock == tmp_path / "data/nirj/state/apply.lock" + assert paths.generated_dir == tmp_path / "data/nirj/cache/generated" assert paths.maintenance_flag == ( tmp_path / "boot/firmware/nirj-maintenance" ) diff --git a/tests/test_update_state.py b/tests/test_update_state.py new file mode 100644 index 0000000..85064e2 --- /dev/null +++ b/tests/test_update_state.py @@ -0,0 +1,17 @@ +from nirj_agent.update import ( + UpdatePhase, + UpdateState, + load_update_state, + save_update_state, +) + + +def test_missing_update_state_is_normal(tmp_path) -> None: + assert load_update_state(tmp_path / "update.json") == UpdateState() + + +def test_update_state_round_trip(tmp_path) -> None: + path = tmp_path / "update.json" + expected = UpdateState(UpdatePhase.PENDING, "abc123", None) + save_update_state(expected, path) + assert load_update_state(path) == expected