diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5c58824 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install project and test dependencies + run: python -m pip install --upgrade pip -e ".[dev]" + + - name: Run tests + run: python -m pytest -q + + package: + name: Build and smoke-test package + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + + - name: Install build tooling + run: python -m pip install --upgrade pip build + + - name: Build source and wheel distributions + run: python -m build + + - name: Install and smoke-test wheel + run: | + python -m pip install dist/*.whl + nirj-agent --help + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..991e455 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.venv/ +.sandbox/ diff --git a/README.md b/README.md index bf9fdb5..f433c43 100644 --- a/README.md +++ b/README.md @@ -1 +1,88 @@ # nirj-agent + +Device management agent for Northern Ireland Raspberry Jam devices. + +## Development usage + +Create a virtual environment and install the package in editable mode: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -e '.[dev]' +pytest -q +``` + +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: + +```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 +``` + +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`. + +`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. + +## Production usage + +Run the installer as root with the device's asset ID and type: + +```bash +curl -fsSL \ + https://raw.githubusercontent.com/NIRaspberryJam/nirj-agent/main/install.sh \ + | sudo bash -s -- --asset-id PI5-001 --device-type pi5 +``` + +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. + +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. + +Inspect the service with: + +```bash +sudo systemctl status nirj-agent.service +sudo journalctl -u nirj-agent.service -f +``` + +Production commands use `/opt/nirj-agent/venv` and omit `--root`: + +```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 +``` + +`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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5511fb4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "nirj-agent" +version = "0.1.0" +description = "Device management agent for Northern Ireland Raspberry Jam" +requires-python = ">=3.11" +dependencies = [ + "PyYAML>=6.0,<7", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8,<9", +] + +[project.scripts] +nirj-agent = "nirj_agent.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/scripts/run-agent.sh b/scripts/run-agent.sh new file mode 100755 index 0000000..099c942 --- /dev/null +++ b/scripts/run-agent.sh @@ -0,0 +1,18 @@ +#!/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 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}" + +"${VENV_DIR}/bin/python" -m pip install \ + --upgrade \ + "${REPO_DIR}" + +exec "${VENV_DIR}/bin/nirj-agent" up diff --git a/src/nirj_agent/__init__.py b/src/nirj_agent/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/nirj_agent/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/nirj_agent/cli/__init__.py b/src/nirj_agent/cli/__init__.py new file mode 100644 index 0000000..21db616 --- /dev/null +++ b/src/nirj_agent/cli/__init__.py @@ -0,0 +1,3 @@ +from .main import main + +__all__ = ["main"] diff --git a/src/nirj_agent/cli/main.py b/src/nirj_agent/cli/main.py new file mode 100644 index 0000000..39c4398 --- /dev/null +++ b/src/nirj_agent/cli/main.py @@ -0,0 +1,253 @@ +import argparse +import json +import os +import signal +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 +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.manifest import refresh_manifest +from nirj_agent.services.plan import PlanError, create_plan +from nirj_agent.services.runner import run_agent +from nirj_agent.state import load_state +from nirj_agent.storage.files import FileStoreError +from nirj_agent.storage.lock import LockError +from nirj_agent.storage.paths import AgentPaths +from nirj_agent.storage.yaml import 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", + ) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + 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( + paths=paths, + package_provider=AptProvider(), + ) + 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( + paths=paths, + package_provider=AptProvider(), + ) + 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) + document = refresh_manifest( + config=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 + + return 2 diff --git a/src/nirj_agent/config/__init__.py b/src/nirj_agent/config/__init__.py new file mode 100644 index 0000000..963a30d --- /dev/null +++ b/src/nirj_agent/config/__init__.py @@ -0,0 +1,12 @@ +from .models import AgentConfig, DeviceConfig, DeviceType, ManifestSource +from .store import ConfigError, create_config, load_config + +__all__ = [ + "AgentConfig", + "ConfigError", + "DeviceConfig", + "DeviceType", + "ManifestSource", + "create_config", + "load_config", +] diff --git a/src/nirj_agent/config/models.py b/src/nirj_agent/config/models.py new file mode 100644 index 0000000..c024264 --- /dev/null +++ b/src/nirj_agent/config/models.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from enum import StrEnum +from uuid import UUID + + +class DeviceType(StrEnum): + PI5 = "pi5" + LAPTOP_LINUX = "lpt-lx" + LAPTOP_WINDOWS = "lpt-win" + + +@dataclass(frozen=True) +class ManifestSource: + type: str + repo: str + path: str + ref: str = "main" + + +@dataclass(frozen=True) +class DeviceConfig: + id: UUID + asset_id: str + type: DeviceType + + +@dataclass(frozen=True) +class AgentConfig: + device: DeviceConfig + manifest: ManifestSource + overlay_enabled: bool + background_enabled: bool diff --git a/src/nirj_agent/config/store.py b/src/nirj_agent/config/store.py new file mode 100644 index 0000000..d367ffc --- /dev/null +++ b/src/nirj_agent/config/store.py @@ -0,0 +1,73 @@ +from pathlib import Path +from uuid import UUID, uuid4 + +from nirj_agent.storage.paths import CONFIG_PATH +from nirj_agent.storage.yaml import read_yaml, write_yaml + +from .models import AgentConfig, DeviceConfig, DeviceType, ManifestSource + + +class ConfigError(ValueError): + pass + + +def load_config(path: Path = CONFIG_PATH) -> AgentConfig: + data = read_yaml(path) + + try: + device = data["device"] + source = data["manifest"]["source"] + + return AgentConfig( + device=DeviceConfig( + id=UUID(str(device["id"])), + asset_id=str(device["asset_id"]), + type=DeviceType(str(device["type"])), + ), + manifest=ManifestSource( + type=str(source["type"]), + repo=str(source["repo"]), + path=str(source["path"]), + ref=str(source.get("ref", "main")), + ), + overlay_enabled=bool( + data.get("overlay", {}).get("enabled", False) + ), + background_enabled=bool( + data.get("background", {}).get("enabled", False) + ), + ) + except (KeyError, TypeError, ValueError) as exc: + raise ConfigError(f"Invalid configuration in {path}: {exc}") from exc + + +def create_config( + asset_id: str, + device_type: DeviceType, + path: Path = CONFIG_PATH, +) -> AgentConfig: + if path.exists(): + raise ConfigError( + f"Configuration already exists at {path}; " + "setup will not replace the device UUID" + ) + + data = { + "device": { + "id": str(uuid4()), + "asset_id": asset_id, + "type": device_type.value, + }, + "manifest": { + "source": { + "type": "github", + "repo": "NIRaspberryJam/nirj-infra", + "path": f"manifests/{device_type.value}.manifest.yaml", + "ref": "main", + } + }, + "overlay": {"enabled": True}, + "background": {"enabled": True}, + } + write_yaml(path, data) + return load_config(path) diff --git a/src/nirj_agent/manifests/__init__.py b/src/nirj_agent/manifests/__init__.py new file mode 100644 index 0000000..0f5ac95 --- /dev/null +++ b/src/nirj_agent/manifests/__init__.py @@ -0,0 +1,11 @@ +from .models import AptManifest, Manifest, ManifestDocument +from .parser import ManifestError, load_manifest, parse_manifest + +__all__ = [ + "AptManifest", + "Manifest", + "ManifestDocument", + "ManifestError", + "load_manifest", + "parse_manifest", +] \ No newline at end of file diff --git a/src/nirj_agent/manifests/github.py b/src/nirj_agent/manifests/github.py new file mode 100644 index 0000000..4085d7b --- /dev/null +++ b/src/nirj_agent/manifests/github.py @@ -0,0 +1,76 @@ +from pathlib import PurePosixPath +import re + +from urllib.request import urlopen +from urllib.parse import quote + +from nirj_agent.config.models import ManifestSource + + +REPOSITORY_PATTERN = re.compile( + r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" +) + +class ManifestDownloadError(RuntimeError): + pass + +class GitHubManifestClient: + def __init__( + self, + timeout: float = 10, + maximum_size: int = 1024 * 1024, + opener=urlopen, + ) -> None: + self.timeout = timeout + self.maximum_size = maximum_size + self.opener = opener + + def fetch(self, source: ManifestSource) -> tuple[str, bytes]: + if source.type != "github": + raise ManifestDownloadError(f"Unsupported manifest source type: {source.type}") + + url = self.build_url(source) + + try: + with self.opener(url, timeout=self.timeout) as response: + content = response.read(self.maximum_size + 1) + except OSError as exc: + raise ManifestDownloadError( + f"Unable to download manifest from {url}: {exc}" + ) from exc + + if len(content) > self.maximum_size: + raise ManifestDownloadError( + f"Manifest exceeds {self.maximum_size} bytes" + ) + + return url, content + + @staticmethod + def build_url(source: ManifestSource) -> str: + if not REPOSITORY_PATTERN.fullmatch(source.repo): + raise ManifestDownloadError( + f"Invalid GitHub repository: {source.repo}" + ) + + if not source.ref: + raise ManifestDownloadError("Manifest ref cannot be empty") + + manifest_path = PurePosixPath(source.path) + + if ( + not source.path.strip() + or manifest_path.is_absolute() + or ".." in manifest_path.parts + ): + raise ManifestDownloadError( + f"Invalid manifest path: {source.path}" + ) + + encoded_ref = quote(source.ref, safe="") + encoded_path = quote(source.path, safe="/") + + return ( + "https://raw.githubusercontent.com/" + f"{source.repo}/{encoded_ref}/{encoded_path}" + ) diff --git a/src/nirj_agent/manifests/models.py b/src/nirj_agent/manifests/models.py new file mode 100644 index 0000000..cd26f86 --- /dev/null +++ b/src/nirj_agent/manifests/models.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AptManifest: + enforce: bool + packages: tuple[str, ...] + + +@dataclass(frozen=True) +class Manifest: + schema: int + apt: AptManifest + overlay_enabled: bool + background_enabled: bool + +@dataclass(frozen=True) +class ManifestDocument: + manifest: Manifest + sha256: str + source_url: str + content: bytes diff --git a/src/nirj_agent/manifests/parser.py b/src/nirj_agent/manifests/parser.py new file mode 100644 index 0000000..c4a32ca --- /dev/null +++ b/src/nirj_agent/manifests/parser.py @@ -0,0 +1,128 @@ +from collections.abc import Mapping +from pathlib import Path +import re +from typing import Any + +import yaml + +from .models import AptManifest, Manifest + + +PACKAGE_NAME_PATTERN = re.compile( + r"^[a-z0-9][a-z0-9+.-]*(?::[a-z0-9][a-z0-9-]*)?$" +) + +class ManifestError(ValueError): + pass + +def parse_manifest(content: bytes, source: str = "") -> Manifest: + try: + text = content.decode("utf-8") + data = yaml.safe_load(text) + except UnicodeDecodeError as exc: + raise ManifestError( + f"Manifest from {source} is not valid UTF-8: {exc}" + ) from exc + except yaml.YAMLError as exc: + raise ManifestError( + f"Manifest from {source} contains invalid YAML: {exc}" + ) from exc + + return manifest_from_mapping(data, source) + +def load_manifest(path: Path) -> Manifest: + try: + content = path.read_bytes() + except OSError as exc: + raise ManifestError(f"Unable to read manifest {path}: {exc}") from exc + + return parse_manifest(content, str(path)) + + + +def manifest_from_mapping(data: object, source: str) -> Manifest: + root = require_mapping(data, "manifest", source) + + schema = root.get("schema") + + if isinstance(schema, bool) or not isinstance(schema, int): + raise ManifestError( + f"Manifest from {source} must define schema as an integer" + ) + + if schema != 1: + raise ManifestError( + f"Unsupported manifest schema from {source}: {schema}" + ) + + apt = require_mapping(root.get("apt", {}), "apt", source) + packages = apt.get("packages", []) + + if not isinstance(packages, list) or not all( + isinstance(package, str) and PACKAGE_NAME_PATTERN.fullmatch(package) + for package in packages + ): + raise ManifestError( + f"apt.packages in {source} must be a list of package names" + ) + + return Manifest( + schema=schema, + apt=AptManifest( + enforce=read_boolean(apt, "enforce", False, source), + packages=tuple(dict.fromkeys(packages)), + ), + overlay_enabled=read_enabled_section(root, "overlay", source), + background_enabled=read_enabled_section( + root, + "background", + source, + ), + ) + +def require_mapping( + value: object, + field: str, + source: str, +) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ManifestError( + f"{field} in {source} must be a YAML mapping" + ) + + return value + + +def read_boolean( + mapping: Mapping[str, Any], + field: str, + default: bool, + source: str, +) -> bool: + value = mapping.get(field, default) + + if not isinstance(value, bool): + raise ManifestError( + f"{field} in {source} must be true or false" + ) + + return value + + +def read_enabled_section( + root: Mapping[str, Any], + section_name: str, + source: str, +) -> bool: + section = require_mapping( + root.get(section_name, {}), + section_name, + source, + ) + + return read_boolean( + section, + "enabled", + False, + source, + ) diff --git a/src/nirj_agent/providers/__init__.py b/src/nirj_agent/providers/__init__.py new file mode 100644 index 0000000..d1d89a1 --- /dev/null +++ b/src/nirj_agent/providers/__init__.py @@ -0,0 +1,5 @@ +"""System integration providers.""" + +from .apt import AptProvider, AptProviderError + +__all__ = ["AptProvider", "AptProviderError"] diff --git a/src/nirj_agent/providers/apt.py b/src/nirj_agent/providers/apt.py new file mode 100644 index 0000000..0c37010 --- /dev/null +++ b/src/nirj_agent/providers/apt.py @@ -0,0 +1,86 @@ +import subprocess +from collections.abc import Callable +from typing import Any + + +class AptProviderError(RuntimeError): + pass + + +class AptProvider: + def __init__( + self, + runner: Callable[..., Any] = subprocess.run, + command_timeout: int = 1800, + ) -> None: + self.runner = runner + self.command_timeout = command_timeout + + def list_installed(self) -> set[str]: + try: + result = self.runner( + [ + "dpkg-query", + "-W", + "-f=${binary:Package}\t${db:Status-Abbrev}\n", + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise AptProviderError( + f"Unable to query installed packages: {exc}" + ) from exc + + if result.returncode != 0: + error = result.stderr.strip() or "unknown dpkg-query error" + raise AptProviderError(f"dpkg-query failed: {error}") + + packages: set[str] = set() + + for line in result.stdout.splitlines(): + package, separator, status = line.partition("\t") + + if separator and package and status.startswith("ii"): + packages.add(package) + + return packages + + def update(self) -> None: + self._run_apt(["apt-get", "update"], "apt-get update") + + def install(self, packages: tuple[str, ...]) -> None: + if not packages: + return + + self._run_apt( + ["apt-get", "install", "--yes", "--no-remove", "--", *packages], + "package installation", + ) + + def remove(self, packages: tuple[str, ...]) -> None: + if not packages: + return + + self._run_apt( + ["apt-get", "remove", "--yes", "--", *packages], + "package removal", + ) + + def _run_apt(self, command: list[str], operation: str) -> None: + try: + result = self.runner( + command, + check=False, + capture_output=True, + text=True, + timeout=self.command_timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise AptProviderError(f"Unable to run {operation}: {exc}") from exc + + if result.returncode != 0: + error = result.stderr.strip() or f"exit code {result.returncode}" + raise AptProviderError(f"{operation} failed: {error}") diff --git a/src/nirj_agent/services/__init__.py b/src/nirj_agent/services/__init__.py new file mode 100644 index 0000000..7abefe4 --- /dev/null +++ b/src/nirj_agent/services/__init__.py @@ -0,0 +1,15 @@ +"""Application orchestration services.""" + +from .apply import ApplyError, ApplyResult, apply_manifest +from .plan import PlanError, create_plan +from .reconciliation import PackagePlan, build_package_plan + +__all__ = [ + "ApplyError", + "ApplyResult", + "PackagePlan", + "PlanError", + "apply_manifest", + "build_package_plan", + "create_plan", +] diff --git a/src/nirj_agent/services/apply.py b/src/nirj_agent/services/apply.py new file mode 100644 index 0000000..f1616a7 --- /dev/null +++ b/src/nirj_agent/services/apply.py @@ -0,0 +1,79 @@ +import hashlib +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Protocol + +from nirj_agent.config import DeviceType, load_config +from nirj_agent.manifests import parse_manifest +from nirj_agent.state import AgentState, load_state, save_state +from nirj_agent.storage.files import read_bytes +from nirj_agent.storage.lock import exclusive_lock +from nirj_agent.storage.paths import AgentPaths + +from .reconciliation import PackagePlan, build_package_plan + + +class ApplyError(RuntimeError): + pass + + +class PackageApplyProvider(Protocol): + def list_installed(self) -> set[str]: ... + + def update(self) -> None: ... + + def install(self, packages: tuple[str, ...]) -> None: ... + + def remove(self, packages: tuple[str, ...]) -> None: ... + + +@dataclass(frozen=True) +class ApplyResult: + plan: PackagePlan + state: AgentState + + +def apply_manifest( + paths: AgentPaths, + package_provider: PackageApplyProvider, + clock: Callable[[], datetime] | None = None, +) -> ApplyResult: + now = clock or (lambda: datetime.now(timezone.utc)) + + with exclusive_lock(paths.apply_lock): + config = load_config(paths.config) + + if config.device.type is DeviceType.LAPTOP_WINDOWS: + raise ApplyError( + "Package application is not supported for Windows devices" + ) + + content = read_bytes(paths.manifest_cache) + manifest = parse_manifest(content, str(paths.manifest_cache)) + previous_state = load_state(paths.state) + installed = package_provider.list_installed() + plan = build_package_plan( + manifest=manifest, + installed_packages=installed, + previously_managed_packages=set(previous_state.packages), + ) + + if plan.install: + package_provider.update() + package_provider.install(plan.install) + + if plan.remove: + package_provider.remove(plan.remove) + + applied_at = now().astimezone(timezone.utc) + state = AgentState( + manifest_hash=hashlib.sha256(content).hexdigest(), + last_apply=applied_at.isoformat().replace("+00:00", "Z"), + packages=plan.desired, + overlay_enabled=False, + ready=False, + ) + save_state(state, paths.state) + + return ApplyResult(plan=plan, state=state) diff --git a/src/nirj_agent/services/manifest.py b/src/nirj_agent/services/manifest.py new file mode 100644 index 0000000..f7ea917 --- /dev/null +++ b/src/nirj_agent/services/manifest.py @@ -0,0 +1,29 @@ +import hashlib + +from nirj_agent.config.models import AgentConfig +from nirj_agent.manifests.github import GitHubManifestClient +from nirj_agent.manifests.models import ManifestDocument +from nirj_agent.manifests.parser import parse_manifest +from nirj_agent.storage.files import write_bytes +from nirj_agent.storage.paths import AgentPaths + + +def refresh_manifest( + config: AgentConfig, + paths: AgentPaths, + client: GitHubManifestClient, +) -> ManifestDocument: + source_url, content = client.fetch(config.manifest) + + # Validate before replacing the working cache. + manifest = parse_manifest(content, source_url) + digest = hashlib.sha256(content).hexdigest() + + write_bytes(paths.manifest_cache, content) + + return ManifestDocument( + manifest=manifest, + sha256=digest, + source_url=source_url, + content=content, + ) \ No newline at end of file diff --git a/src/nirj_agent/services/plan.py b/src/nirj_agent/services/plan.py new file mode 100644 index 0000000..4670f99 --- /dev/null +++ b/src/nirj_agent/services/plan.py @@ -0,0 +1,38 @@ +from typing import Protocol + +from nirj_agent.config import DeviceType, load_config +from nirj_agent.manifests import load_manifest +from nirj_agent.state import load_state +from nirj_agent.storage.paths import AgentPaths + +from .reconciliation import PackagePlan, build_package_plan + + +class PlanError(RuntimeError): + pass + + +class InstalledPackageProvider(Protocol): + def list_installed(self) -> set[str]: ... + + +def create_plan( + paths: AgentPaths, + package_provider: InstalledPackageProvider, +) -> PackagePlan: + config = load_config(paths.config) + + if config.device.type is DeviceType.LAPTOP_WINDOWS: + raise PlanError( + "Package planning is not supported for Windows devices" + ) + + manifest = load_manifest(paths.manifest_cache) + state = load_state(paths.state) + installed = package_provider.list_installed() + + return build_package_plan( + manifest=manifest, + installed_packages=installed, + previously_managed_packages=set(state.packages), + ) diff --git a/src/nirj_agent/services/reconciliation.py b/src/nirj_agent/services/reconciliation.py new file mode 100644 index 0000000..685ef78 --- /dev/null +++ b/src/nirj_agent/services/reconciliation.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass + +from nirj_agent.manifests import Manifest + + +@dataclass(frozen=True) +class PackagePlan: + desired: tuple[str, ...] + install: tuple[str, ...] + remove: tuple[str, ...] + unchanged: tuple[str, ...] + + @property + def changes_required(self) -> bool: + return bool(self.install or self.remove) + + +def build_package_plan( + manifest: Manifest, + installed_packages: set[str], + previously_managed_packages: set[str], +) -> PackagePlan: + desired = set(manifest.apt.packages) + install = desired - installed_packages + unchanged = desired & installed_packages + remove = ( + previously_managed_packages - desired + if manifest.apt.enforce + else set() + ) + + return PackagePlan( + desired=tuple(sorted(desired)), + install=tuple(sorted(install)), + remove=tuple(sorted(remove)), + unchanged=tuple(sorted(unchanged)), + ) diff --git a/src/nirj_agent/services/runner.py b/src/nirj_agent/services/runner.py new file mode 100644 index 0000000..f99662f --- /dev/null +++ b/src/nirj_agent/services/runner.py @@ -0,0 +1,31 @@ +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) + stop_event.wait() + + return result diff --git a/src/nirj_agent/state/__init__.py b/src/nirj_agent/state/__init__.py new file mode 100644 index 0000000..da84cad --- /dev/null +++ b/src/nirj_agent/state/__init__.py @@ -0,0 +1,4 @@ +from .models import AgentState +from .store import load_state, save_state + +__all__ = ["AgentState", "load_state", "save_state"] diff --git a/src/nirj_agent/state/models.py b/src/nirj_agent/state/models.py new file mode 100644 index 0000000..66614e9 --- /dev/null +++ b/src/nirj_agent/state/models.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgentState: + manifest_hash: str | None + last_apply: str | None + packages: tuple[str, ...] + overlay_enabled: bool + ready: bool diff --git a/src/nirj_agent/state/store.py b/src/nirj_agent/state/store.py new file mode 100644 index 0000000..85ced7e --- /dev/null +++ b/src/nirj_agent/state/store.py @@ -0,0 +1,28 @@ +from dataclasses import asdict +from pathlib import Path + +from nirj_agent.storage.paths import STATE_PATH +from nirj_agent.storage.yaml import read_yaml, write_yaml + +from .models import AgentState + + +def load_state(path: Path = STATE_PATH) -> AgentState: + if not path.exists(): + return AgentState(None, None, (), False, False) + + data = read_yaml(path) + return AgentState( + manifest_hash=data.get("manifest_hash"), + last_apply=data.get("last_apply"), + packages=tuple(data.get("packages", [])), + overlay_enabled=bool(data.get("overlay", {}).get("enabled", False)), + ready=bool(data.get("ready", False)), + ) + + +def save_state(state: AgentState, path: Path = STATE_PATH) -> None: + data = asdict(state) + data["packages"] = list(state.packages) + data["overlay"] = {"enabled": data.pop("overlay_enabled")} + write_yaml(path, data) diff --git a/src/nirj_agent/storage/__init__.py b/src/nirj_agent/storage/__init__.py new file mode 100644 index 0000000..79ded91 --- /dev/null +++ b/src/nirj_agent/storage/__init__.py @@ -0,0 +1,16 @@ +from .files import FileStoreError, read_bytes, write_bytes +from .lock import LockError, exclusive_lock +from .paths import AgentPaths +from .yaml import YamlStoreError, read_yaml, write_yaml + +__all__ = [ + "AgentPaths", + "FileStoreError", + "LockError", + "YamlStoreError", + "exclusive_lock", + "read_bytes", + "read_yaml", + "write_bytes", + "write_yaml", +] diff --git a/src/nirj_agent/storage/files.py b/src/nirj_agent/storage/files.py new file mode 100644 index 0000000..df7a847 --- /dev/null +++ b/src/nirj_agent/storage/files.py @@ -0,0 +1,28 @@ +import os +from pathlib import Path + +class FileStoreError(RuntimeError): + pass + + +def read_bytes(path: Path) -> bytes: + try: + return path.read_bytes() + except OSError as exc: + raise FileStoreError(f"Unable to read {path}: {exc}") from exc + + +def write_bytes(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = path.with_suffix(f"{path.suffix}.tmp") + + try: + with temporary_path.open("wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + + temporary_path.replace(path) + except OSError as exc: + temporary_path.unlink(missing_ok=True) + raise FileStoreError(f"Unable to write {path}: {exc}") from exc diff --git a/src/nirj_agent/storage/lock.py b/src/nirj_agent/storage/lock.py new file mode 100644 index 0000000..aad34ab --- /dev/null +++ b/src/nirj_agent/storage/lock.py @@ -0,0 +1,33 @@ +import fcntl +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import TextIO + + +class LockError(RuntimeError): + pass + + +@contextmanager +def exclusive_lock(path: Path) -> Iterator[None]: + handle: TextIO | None = None + + try: + path.parent.mkdir(parents=True, exist_ok=True) + handle = path.open("a+", encoding="utf-8") + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + if handle is not None: + handle.close() + raise LockError(f"Another apply operation is already running") from exc + except OSError as exc: + if handle is not None: + handle.close() + raise LockError(f"Unable to acquire apply lock {path}: {exc}") from exc + + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + handle.close() diff --git a/src/nirj_agent/storage/paths.py b/src/nirj_agent/storage/paths.py new file mode 100644 index 0000000..b628767 --- /dev/null +++ b/src/nirj_agent/storage/paths.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class AgentPaths: + config: Path + state: Path + manifest_cache: Path + apply_lock: Path + generated_dir: Path + maintenance_flag: Path + base_background: Path + + @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"), + maintenance_flag=Path("/boot/firmware/nirj-maintenance"), + base_background=Path( + "/usr/share/nirj-agent/background-base.png" + ), + ) + + @classmethod + def sandbox(cls, root: Path) -> "AgentPaths": + 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", + maintenance_flag=root / "boot/firmware/nirj-maintenance", + base_background=( + root / "usr/share/nirj-agent/background-base.png" + ), + ) + + +SYSTEM_PATHS = AgentPaths.system() + +CONFIG_PATH = SYSTEM_PATHS.config +STATE_PATH = SYSTEM_PATHS.state +MANIFEST_CACHE_PATH = SYSTEM_PATHS.manifest_cache +APPLY_LOCK_PATH = SYSTEM_PATHS.apply_lock +GENERATED_DIR = SYSTEM_PATHS.generated_dir +MAINTENANCE_FLAG_PATH = SYSTEM_PATHS.maintenance_flag +BASE_BACKGROUND_PATH = SYSTEM_PATHS.base_background diff --git a/src/nirj_agent/storage/yaml.py b/src/nirj_agent/storage/yaml.py new file mode 100644 index 0000000..3026154 --- /dev/null +++ b/src/nirj_agent/storage/yaml.py @@ -0,0 +1,41 @@ +import os +from pathlib import Path +from typing import Any + +import yaml + + +class YamlStoreError(RuntimeError): + pass + + +def read_yaml(path: Path) -> dict[str, Any]: + try: + with path.open(encoding="utf-8") as stream: + value = yaml.safe_load(stream) + except (OSError, yaml.YAMLError) as exc: + raise YamlStoreError(f"Unable to read {path}: {exc}") from exc + + if value is None: + return {} + + if not isinstance(value, dict): + raise YamlStoreError(f"{path} must contain a YAML mapping") + + return value + + +def write_yaml(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: + yaml.safe_dump(value, stream, sort_keys=False) + stream.flush() + os.fsync(stream.fileno()) + + temporary_path.replace(path) + except (OSError, yaml.YAMLError) as exc: + temporary_path.unlink(missing_ok=True) + raise YamlStoreError(f"Unable to write {path}: {exc}") from exc diff --git a/tests/manifests/__init__.py b/tests/manifests/__init__.py new file mode 100644 index 0000000..5ece0dc --- /dev/null +++ b/tests/manifests/__init__.py @@ -0,0 +1 @@ +"""Manifest tests.""" diff --git a/tests/manifests/test_github.py b/tests/manifests/test_github.py new file mode 100644 index 0000000..f5b917a --- /dev/null +++ b/tests/manifests/test_github.py @@ -0,0 +1,119 @@ +from urllib.error import URLError + +import pytest + +from nirj_agent.config import ManifestSource +from nirj_agent.manifests.github import ( + GitHubManifestClient, + ManifestDownloadError, +) + + +class FakeResponse: + def __init__(self, content: bytes) -> None: + self.content = content + self.requested_size: int | None = None + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self, size: int) -> bytes: + self.requested_size = size + return self.content[:size] + + +def source(**overrides) -> ManifestSource: + values = { + "type": "github", + "repo": "NIRaspberryJam/nirj-infra", + "path": "manifests/pi5.manifest.yaml", + "ref": "main", + } + values.update(overrides) + return ManifestSource(**values) + + +def test_build_url() -> None: + assert GitHubManifestClient.build_url(source()) == ( + "https://raw.githubusercontent.com/" + "NIRaspberryJam/nirj-infra/main/manifests/pi5.manifest.yaml" + ) + + +def test_build_url_encodes_ref_and_path() -> None: + result = GitHubManifestClient.build_url( + source(ref="feature/test", path="manifests/test file.yaml") + ) + + assert "/feature%2Ftest/" in result + assert result.endswith("manifests/test%20file.yaml") + + +@pytest.mark.parametrize( + "overrides", + [ + {"repo": "missing-owner"}, + {"ref": ""}, + {"path": ""}, + {"path": "/absolute.yaml"}, + {"path": "manifests/../secret.yaml"}, + ], +) +def test_build_url_rejects_unsafe_source(overrides) -> None: + with pytest.raises(ManifestDownloadError): + GitHubManifestClient.build_url(source(**overrides)) + + +def test_fetch_returns_url_and_content() -> None: + response = FakeResponse(b"schema: 1\n") + + def opener(_url, timeout): + assert timeout == 3 + return response + + client = GitHubManifestClient( + timeout=3, + maximum_size=100, + opener=opener, + ) + + url, content = client.fetch(source()) + + assert url.endswith("/main/manifests/pi5.manifest.yaml") + assert content == b"schema: 1\n" + assert response.requested_size == 101 + + +def test_fetch_rejects_unsupported_source_type() -> None: + client = GitHubManifestClient(opener=lambda *_args, **_kwargs: None) + + with pytest.raises(ManifestDownloadError, match="Unsupported"): + client.fetch(source(type="local")) + + +def test_fetch_rejects_oversized_manifest() -> None: + response = FakeResponse(b"123456") + client = GitHubManifestClient( + maximum_size=5, + opener=lambda *_args, **_kwargs: response, + ) + + with pytest.raises(ManifestDownloadError, match="exceeds 5 bytes"): + client.fetch(source()) + + +@pytest.mark.parametrize( + "error", + [URLError("offline"), TimeoutError("timed out"), OSError("socket failed")], +) +def test_fetch_wraps_network_failures(error: OSError) -> None: + def opener(*_args, **_kwargs): + raise error + + client = GitHubManifestClient(opener=opener) + + with pytest.raises(ManifestDownloadError, match="Unable to download"): + client.fetch(source()) diff --git a/tests/manifests/test_parser.py b/tests/manifests/test_parser.py new file mode 100644 index 0000000..d549b06 --- /dev/null +++ b/tests/manifests/test_parser.py @@ -0,0 +1,94 @@ +from pathlib import Path + +import pytest + +from nirj_agent.manifests import ManifestError, load_manifest, parse_manifest + + +VALID_MANIFEST = b"""\ +schema: 1 +apt: + enforce: true + packages: + - thonny + - git +overlay: + enabled: false +background: + enabled: true +""" + + +def test_parse_manifest_from_bytes() -> None: + manifest = parse_manifest(VALID_MANIFEST, "test manifest") + + assert manifest.schema == 1 + assert manifest.apt.enforce is True + assert manifest.apt.packages == ("thonny", "git") + assert manifest.overlay_enabled is False + assert manifest.background_enabled is True + + +def test_load_manifest_reads_file(tmp_path: Path) -> None: + path = tmp_path / "manifest.yaml" + path.write_bytes(VALID_MANIFEST) + + assert load_manifest(path) == parse_manifest(VALID_MANIFEST) + + +def test_parse_manifest_rejects_invalid_utf8() -> None: + with pytest.raises(ManifestError, match="not valid UTF-8"): + parse_manifest(b"\xff", "invalid manifest") + + +def test_parse_manifest_rejects_invalid_yaml() -> None: + with pytest.raises(ManifestError, match="invalid YAML"): + parse_manifest(b"schema: [", "invalid manifest") + + +@pytest.mark.parametrize("content", [b"", b"- schema\n- 1\n"]) +def test_parse_manifest_requires_top_level_mapping(content: bytes) -> None: + with pytest.raises(ManifestError, match="must be a YAML mapping"): + parse_manifest(content) + + +@pytest.mark.parametrize("schema", [b"true", b"'1'", b"null"]) +def test_parse_manifest_requires_integer_schema(schema: bytes) -> None: + with pytest.raises(ManifestError, match="schema as an integer"): + parse_manifest(b"schema: " + schema + b"\n") + + +@pytest.mark.parametrize("section", [b"apt", b"overlay", b"background"]) +def test_parse_manifest_requires_section_mappings(section: bytes) -> None: + content = b"schema: 1\n" + section + b": []\n" + + with pytest.raises(ManifestError, match="must be a YAML mapping"): + parse_manifest(content) + + +@pytest.mark.parametrize( + "content", + [ + b"schema: 1\napt:\n enforce: 'false'\n", + b"schema: 1\noverlay:\n enabled: 0\n", + b"schema: 1\nbackground:\n enabled: 'yes'\n", + ], +) +def test_parse_manifest_requires_real_booleans(content: bytes) -> None: + with pytest.raises(ManifestError, match="must be true or false"): + parse_manifest(content) + + +def test_parse_manifest_rejects_blank_package_name() -> None: + content = b"schema: 1\napt:\n packages: [' ']\n" + + with pytest.raises(ManifestError, match="apt.packages"): + parse_manifest(content) + + +@pytest.mark.parametrize("package", ["--option", "UPPERCASE", "bad name"]) +def test_parse_manifest_rejects_unsafe_package_name(package: str) -> None: + content = f"schema: 1\napt:\n packages: ['{package}']\n".encode() + + with pytest.raises(ManifestError, match="apt.packages"): + parse_manifest(content) diff --git a/tests/providers/__init__.py b/tests/providers/__init__.py new file mode 100644 index 0000000..7dacfd3 --- /dev/null +++ b/tests/providers/__init__.py @@ -0,0 +1 @@ +"""Provider tests.""" diff --git a/tests/providers/test_apt.py b/tests/providers/test_apt.py new file mode 100644 index 0000000..076bb52 --- /dev/null +++ b/tests/providers/test_apt.py @@ -0,0 +1,113 @@ +import subprocess +from types import SimpleNamespace + +import pytest + +from nirj_agent.providers import AptProvider, AptProviderError + + +def test_list_installed_parses_only_installed_packages() -> None: + calls = [] + + def runner(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace( + returncode=0, + stdout=( + "git\tii \n" + "python3\tii \n" + "removed-package\trc \n" + "malformed-line\n" + ), + stderr="", + ) + + packages = AptProvider(runner=runner).list_installed() + + assert packages == {"git", "python3"} + command, kwargs = calls[0] + assert command[0:2] == ["dpkg-query", "-W"] + assert kwargs == { + "check": False, + "capture_output": True, + "text": True, + "timeout": 30, + } + + +def test_list_installed_wraps_missing_command() -> None: + def runner(*_args, **_kwargs): + raise FileNotFoundError("dpkg-query") + + with pytest.raises(AptProviderError, match="Unable to query"): + AptProvider(runner=runner).list_installed() + + +def test_list_installed_wraps_timeout() -> None: + def runner(*_args, **_kwargs): + raise subprocess.TimeoutExpired("dpkg-query", 30) + + with pytest.raises(AptProviderError, match="Unable to query"): + AptProvider(runner=runner).list_installed() + + +def test_list_installed_reports_command_failure() -> None: + def runner(*_args, **_kwargs): + return SimpleNamespace(returncode=1, stdout="", stderr="database error") + + with pytest.raises(AptProviderError, match="database error"): + AptProvider(runner=runner).list_installed() + + +def test_update_install_and_remove_use_safe_commands() -> None: + calls = [] + + def runner(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + provider = AptProvider(runner=runner, command_timeout=600) + provider.update() + provider.install(("git", "thonny")) + provider.remove(("obsolete",)) + + assert [call[0] for call in calls] == [ + ["apt-get", "update"], + [ + "apt-get", + "install", + "--yes", + "--no-remove", + "--", + "git", + "thonny", + ], + ["apt-get", "remove", "--yes", "--", "obsolete"], + ] + assert all(call[1]["timeout"] == 600 for call in calls) + + +def test_empty_install_and_remove_execute_nothing() -> None: + calls = [] + provider = AptProvider(runner=lambda *args, **kwargs: calls.append(args)) + + provider.install(()) + provider.remove(()) + + assert calls == [] + + +def test_apt_operation_failure_is_reported() -> None: + def runner(*_args, **_kwargs): + return SimpleNamespace(returncode=100, stdout="", stderr="apt failed") + + with pytest.raises(AptProviderError, match="package installation failed"): + AptProvider(runner=runner).install(("thonny",)) + + +def test_apt_operation_timeout_is_wrapped() -> None: + def runner(*_args, **_kwargs): + raise subprocess.TimeoutExpired("apt-get", 1800) + + with pytest.raises(AptProviderError, match="Unable to run package removal"): + AptProvider(runner=runner).remove(("obsolete",)) diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..432988d --- /dev/null +++ b/tests/services/__init__.py @@ -0,0 +1 @@ +"""Service tests.""" diff --git a/tests/services/test_apply.py b/tests/services/test_apply.py new file mode 100644 index 0000000..3e9edd1 --- /dev/null +++ b/tests/services/test_apply.py @@ -0,0 +1,137 @@ +import hashlib +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from nirj_agent.config import DeviceType, create_config +from nirj_agent.providers import AptProviderError +from nirj_agent.services.apply import ApplyError, apply_manifest +from nirj_agent.state import AgentState, load_state, save_state +from nirj_agent.storage.paths import AgentPaths + + +MANIFEST = b"""\ +schema: 1 +apt: + enforce: true + packages: [git, thonny] +""" + + +class FakeApplyProvider: + def __init__( + self, + installed: set[str], + fail_operation: str | None = None, + ) -> None: + self.installed = installed + self.fail_operation = fail_operation + self.events: list[object] = [] + + def list_installed(self) -> set[str]: + self.events.append("list") + return self.installed + + def update(self) -> None: + self.events.append("update") + self._fail_if_requested("update") + + def install(self, packages: tuple[str, ...]) -> None: + self.events.append(("install", packages)) + self._fail_if_requested("install") + + def remove(self, packages: tuple[str, ...]) -> None: + self.events.append(("remove", packages)) + self._fail_if_requested("remove") + + def _fail_if_requested(self, operation: str) -> None: + if self.fail_operation == operation: + raise AptProviderError(f"{operation} failed") + + +def prepare( + tmp_path: Path, + device_type: DeviceType = DeviceType.PI5, +) -> AgentPaths: + paths = AgentPaths.sandbox(tmp_path) + create_config("DEVICE-001", device_type, paths.config) + paths.manifest_cache.parent.mkdir(parents=True, exist_ok=True) + paths.manifest_cache.write_bytes(MANIFEST) + return paths + + +def test_apply_runs_operations_in_order_and_persists_state( + tmp_path: Path, +) -> None: + paths = prepare(tmp_path) + save_state( + AgentState( + manifest_hash="old", + last_apply=None, + packages=("git", "obsolete"), + overlay_enabled=False, + ready=False, + ), + paths.state, + ) + provider = FakeApplyProvider({"git"}) + applied_at = datetime(2026, 7, 1, 12, 30, tzinfo=timezone.utc) + + result = apply_manifest(paths, provider, clock=lambda: applied_at) + + assert provider.events == [ + "list", + "update", + ("install", ("thonny",)), + ("remove", ("obsolete",)), + ] + assert result.state.manifest_hash == hashlib.sha256(MANIFEST).hexdigest() + assert result.state.last_apply == "2026-07-01T12:30:00Z" + assert result.state.packages == ("git", "thonny") + assert result.state.overlay_enabled is False + assert result.state.ready is False + assert load_state(paths.state) == result.state + + +def test_apply_skips_update_when_no_install_is_needed(tmp_path: Path) -> None: + paths = prepare(tmp_path) + provider = FakeApplyProvider({"git", "thonny"}) + + apply_manifest(paths, provider) + + assert provider.events == ["list"] + + +@pytest.mark.parametrize("failure", ["update", "install", "remove"]) +def test_apply_failure_does_not_replace_previous_state( + tmp_path: Path, + failure: str, +) -> None: + paths = prepare(tmp_path) + previous = AgentState( + manifest_hash="old", + last_apply="2026-06-30T00:00:00Z", + packages=("git", "obsolete"), + overlay_enabled=False, + ready=False, + ) + save_state(previous, paths.state) + installed = {"git"} if failure != "remove" else {"git", "thonny"} + provider = FakeApplyProvider(installed, fail_operation=failure) + + with pytest.raises(AptProviderError, match=failure): + apply_manifest(paths, provider) + + assert load_state(paths.state) == previous + + +def test_apply_rejects_windows_before_querying_packages(tmp_path: Path) -> None: + paths = prepare(tmp_path, DeviceType.LAPTOP_WINDOWS) + provider = FakeApplyProvider(set()) + + with pytest.raises(ApplyError, match="not supported for Windows"): + apply_manifest(paths, provider) + + assert provider.events == [] + assert not paths.state.exists() diff --git a/tests/services/test_manifest.py b/tests/services/test_manifest.py new file mode 100644 index 0000000..74d1efe --- /dev/null +++ b/tests/services/test_manifest.py @@ -0,0 +1,70 @@ +import hashlib +from pathlib import Path +from uuid import UUID + +import pytest + +from nirj_agent.config import ( + AgentConfig, + DeviceConfig, + DeviceType, + ManifestSource, +) +from nirj_agent.manifests import ManifestError +from nirj_agent.services.manifest import refresh_manifest +from nirj_agent.storage.paths import AgentPaths + + +class FakeClient: + def __init__(self, content: bytes) -> None: + self.content = content + self.received_source = None + + def fetch(self, source): + self.received_source = source + return "https://example.test/manifest.yaml", self.content + + +def config() -> AgentConfig: + return AgentConfig( + device=DeviceConfig( + id=UUID("fc41017d-0937-49f2-a49d-a64164d9cc2e"), + asset_id="PI5-001", + type=DeviceType.PI5, + ), + manifest=ManifestSource( + type="github", + repo="NIRaspberryJam/nirj-infra", + path="manifests/pi5.manifest.yaml", + ), + overlay_enabled=False, + background_enabled=False, + ) + + +def test_refresh_manifest_validates_hashes_and_caches(tmp_path: Path) -> None: + content = b"schema: 1\napt:\n packages: [git]\n" + client = FakeClient(content) + paths = AgentPaths.sandbox(tmp_path) + + document = refresh_manifest(config(), paths, client) + + assert document.content == content + assert document.sha256 == hashlib.sha256(content).hexdigest() + assert document.source_url == "https://example.test/manifest.yaml" + assert document.manifest.apt.packages == ("git",) + assert paths.manifest_cache.read_bytes() == content + assert client.received_source == config().manifest + + +def test_refresh_manifest_preserves_cache_when_download_is_invalid( + tmp_path: Path, +) -> None: + paths = AgentPaths.sandbox(tmp_path) + paths.manifest_cache.parent.mkdir(parents=True) + paths.manifest_cache.write_bytes(b"schema: 1\n") + + with pytest.raises(ManifestError): + refresh_manifest(config(), paths, FakeClient(b"schema: [")) + + assert paths.manifest_cache.read_bytes() == b"schema: 1\n" diff --git a/tests/services/test_plan.py b/tests/services/test_plan.py new file mode 100644 index 0000000..73ba7e6 --- /dev/null +++ b/tests/services/test_plan.py @@ -0,0 +1,93 @@ +from pathlib import Path + +import pytest + +from nirj_agent.config import DeviceType, create_config +from nirj_agent.manifests import ManifestError +from nirj_agent.services.plan import PlanError, create_plan +from nirj_agent.state import AgentState, save_state +from nirj_agent.storage.paths import AgentPaths + + +class FakePackageProvider: + def __init__(self, installed: set[str]) -> None: + self.installed = installed + self.called = False + + def list_installed(self) -> set[str]: + self.called = True + return self.installed + + +def write_manifest(path: Path, enforce: bool = True) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + ( + "schema: 1\n" + "apt:\n" + f" enforce: {str(enforce).lower()}\n" + " packages: [git, thonny]\n" + ), + encoding="utf-8", + ) + + +def test_create_plan_uses_config_manifest_state_and_provider( + tmp_path: Path, +) -> None: + paths = AgentPaths.sandbox(tmp_path) + create_config("PI5-001", DeviceType.PI5, paths.config) + write_manifest(paths.manifest_cache) + save_state( + AgentState( + manifest_hash="old-hash", + last_apply=None, + packages=("git", "obsolete"), + overlay_enabled=False, + ready=False, + ), + paths.state, + ) + provider = FakePackageProvider({"git"}) + + plan = create_plan(paths, provider) + + assert provider.called is True + assert plan.install == ("thonny",) + assert plan.remove == ("obsolete",) + assert plan.unchanged == ("git",) + + +def test_create_plan_does_not_write_files(tmp_path: Path) -> None: + paths = AgentPaths.sandbox(tmp_path) + create_config("PI5-001", DeviceType.PI5, paths.config) + write_manifest(paths.manifest_cache) + config_before = paths.config.read_bytes() + manifest_before = paths.manifest_cache.read_bytes() + + create_plan(paths, FakePackageProvider({"git", "thonny"})) + + assert paths.config.read_bytes() == config_before + assert paths.manifest_cache.read_bytes() == manifest_before + assert not paths.state.exists() + + +def test_create_plan_requires_cached_manifest(tmp_path: Path) -> None: + paths = AgentPaths.sandbox(tmp_path) + create_config("PI5-001", DeviceType.PI5, paths.config) + + with pytest.raises(ManifestError, match="Unable to read manifest"): + create_plan(paths, FakePackageProvider(set())) + + +def test_create_plan_rejects_windows_before_querying_packages( + tmp_path: Path, +) -> None: + paths = AgentPaths.sandbox(tmp_path) + create_config("LPT-001", DeviceType.LAPTOP_WINDOWS, paths.config) + provider = FakePackageProvider(set()) + + with pytest.raises(PlanError, match="not supported for Windows"): + create_plan(paths, provider) + + assert provider.called is False diff --git a/tests/services/test_reconciliation.py b/tests/services/test_reconciliation.py new file mode 100644 index 0000000..3a0277e --- /dev/null +++ b/tests/services/test_reconciliation.py @@ -0,0 +1,69 @@ +from nirj_agent.manifests import AptManifest, Manifest +from nirj_agent.services.reconciliation import build_package_plan + + +def manifest(*packages: str, enforce: bool = True) -> Manifest: + return Manifest( + schema=1, + apt=AptManifest(enforce=enforce, packages=packages), + overlay_enabled=False, + background_enabled=False, + ) + + +def test_build_package_plan_classifies_packages() -> None: + plan = build_package_plan( + manifest=manifest("git", "python3", "thonny"), + installed_packages={"git", "python3", "unmanaged"}, + previously_managed_packages={"git", "obsolete"}, + ) + + assert plan.desired == ("git", "python3", "thonny") + assert plan.install == ("thonny",) + assert plan.remove == ("obsolete",) + assert plan.unchanged == ("git", "python3") + assert plan.changes_required is True + + +def test_unmanaged_installed_packages_are_never_removed() -> None: + plan = build_package_plan( + manifest=manifest("git"), + installed_packages={"git", "curl", "python3"}, + previously_managed_packages={"git"}, + ) + + assert plan.remove == () + + +def test_enforcement_disabled_prevents_removal() -> None: + plan = build_package_plan( + manifest=manifest("git", enforce=False), + installed_packages={"git", "obsolete"}, + previously_managed_packages={"git", "obsolete"}, + ) + + assert plan.remove == () + assert plan.changes_required is False + + +def test_package_plan_is_sorted_and_deduplicated() -> None: + plan = build_package_plan( + manifest=manifest("z-package", "a-package", "z-package"), + installed_packages=set(), + previously_managed_packages=set(), + ) + + assert plan.desired == ("a-package", "z-package") + assert plan.install == ("a-package", "z-package") + + +def test_package_plan_reports_no_changes() -> None: + plan = build_package_plan( + manifest=manifest("git"), + installed_packages={"git"}, + previously_managed_packages={"git"}, + ) + + assert plan.install == () + assert plan.remove == () + assert plan.changes_required is False diff --git a/tests/services/test_runner.py b/tests/services/test_runner.py new file mode 100644 index 0000000..427b71b --- /dev/null +++ b/tests/services/test_runner.py @@ -0,0 +1,69 @@ +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: + 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 diff --git a/tests/storage/__init__.py b/tests/storage/__init__.py new file mode 100644 index 0000000..56bd7da --- /dev/null +++ b/tests/storage/__init__.py @@ -0,0 +1 @@ +"""Storage tests.""" diff --git a/tests/storage/test_files.py b/tests/storage/test_files.py new file mode 100644 index 0000000..d962112 --- /dev/null +++ b/tests/storage/test_files.py @@ -0,0 +1,42 @@ +from pathlib import Path + +import pytest + +from nirj_agent.storage.files import FileStoreError, write_bytes + + +def test_write_bytes_creates_parent_and_writes_content(tmp_path: Path) -> None: + path = tmp_path / "nested" / "manifest.yaml" + + write_bytes(path, b"schema: 1\n") + + assert path.read_bytes() == b"schema: 1\n" + assert not path.with_suffix(".yaml.tmp").exists() + + +def test_write_bytes_replaces_existing_file(tmp_path: Path) -> None: + path = tmp_path / "manifest.yaml" + path.write_bytes(b"old") + + write_bytes(path, b"new") + + assert path.read_bytes() == b"new" + + +def test_write_bytes_cleans_up_temporary_file_on_replace_error( + tmp_path: Path, + monkeypatch, +) -> None: + path = tmp_path / "manifest.yaml" + path.write_bytes(b"old") + + def fail_replace(_self, _target): + raise OSError("replace failed") + + monkeypatch.setattr(Path, "replace", fail_replace) + + with pytest.raises(FileStoreError, match="replace failed"): + write_bytes(path, b"new") + + assert path.read_bytes() == b"old" + assert not path.with_suffix(".yaml.tmp").exists() diff --git a/tests/storage/test_lock.py b/tests/storage/test_lock.py new file mode 100644 index 0000000..af7e500 --- /dev/null +++ b/tests/storage/test_lock.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import pytest + +from nirj_agent.storage.lock import LockError, exclusive_lock + + +def test_exclusive_lock_creates_lock_file(tmp_path: Path) -> None: + path = tmp_path / "run" / "apply.lock" + + with exclusive_lock(path): + assert path.exists() + + +def test_exclusive_lock_rejects_second_holder(tmp_path: Path) -> None: + path = tmp_path / "apply.lock" + + with exclusive_lock(path): + with pytest.raises(LockError, match="already running"): + with exclusive_lock(path): + pass diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..07f62f1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,107 @@ +from importlib import import_module +from uuid import UUID + +from nirj_agent.config import ( + AgentConfig, + DeviceConfig, + DeviceType, + ManifestSource, +) +from nirj_agent.state import AgentState + +cli = import_module("nirj_agent.cli.main") + + +def test_status_returns_zero_when_ready(monkeypatch, capsys) -> None: + monkeypatch.setattr( + cli, + "load_state", + lambda _path: AgentState("abc123", None, ("thonny",), True, True), + ) + result = cli.main(["status"]) + assert result == 0 + assert '"ready": true' in capsys.readouterr().out + + +def test_status_returns_one_when_not_ready(monkeypatch, capsys) -> None: + monkeypatch.setattr( + cli, + "load_state", + lambda _path: AgentState(None, None, (), False, False), + ) + result = cli.main(["status"]) + assert result == 1 + assert '"ready": false' in capsys.readouterr().out + + +def test_get_config_calls_loader(monkeypatch, capsys) -> None: + config = AgentConfig( + device=DeviceConfig( + id=UUID("fc41017d-0937-49f2-a49d-a64164d9cc2e"), + asset_id="PI5-001", + type=DeviceType.PI5, + ), + manifest=ManifestSource( + type="github", + repo="NIRaspberryJam/nirj-infra", + path="manifests/pi5.manifest.yaml", + ), + overlay_enabled=True, + background_enabled=True, + ) + monkeypatch.setattr(cli, "load_config", lambda _path: config) + result = cli.main(["get-config"]) + output = capsys.readouterr().out + assert result == 0 + assert '"asset_id": "PI5-001"' in output + assert str(config.device.id) in output + + +def test_status_uses_sandbox_state_path(tmp_path, monkeypatch, capsys) -> None: + loaded_paths = [] + + def load_test_state(path): + loaded_paths.append(path) + return AgentState(None, None, (), False, False) + + monkeypatch.setattr(cli, "load_state", load_test_state) + + result = cli.main(["--root", str(tmp_path), "status"]) + + assert result == 1 + assert loaded_paths == [tmp_path / "var/lib/nirj-agent/state.yaml"] + capsys.readouterr() + + +def test_up_rejects_sandbox_root(tmp_path, capsys) -> None: + result = cli.main(["--root", str(tmp_path), "up"]) + + assert result == 1 + assert "does not support --root" in capsys.readouterr().err + + +def test_up_requires_root(monkeypatch, capsys) -> None: + monkeypatch.setattr(cli.os, "geteuid", lambda: 1000) + + result = cli.main(["up"]) + + assert result == 1 + assert "must run as root" in capsys.readouterr().err + + +def test_up_runs_agent_as_root(monkeypatch) -> None: + received = {} + monkeypatch.setattr(cli.os, "geteuid", lambda: 0) + + def run_test_agent(**kwargs) -> None: + received.update(kwargs) + + monkeypatch.setattr(cli, "run_agent", run_test_agent) + + result = cli.main(["up"]) + + 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) diff --git a/tests/test_cli_apply.py b/tests/test_cli_apply.py new file mode 100644 index 0000000..fa23ebf --- /dev/null +++ b/tests/test_cli_apply.py @@ -0,0 +1,80 @@ +from importlib import import_module +from pathlib import Path +from types import SimpleNamespace + +from nirj_agent.providers import AptProviderError +from nirj_agent.services.reconciliation import PackagePlan +from nirj_agent.state import AgentState + + +cli = import_module("nirj_agent.cli.main") + + +def test_apply_rejects_sandbox_root(tmp_path: Path, capsys) -> None: + result = cli.main(["--root", str(tmp_path), "apply"]) + + output = capsys.readouterr() + assert result == 1 + assert "does not support --root" in output.err + + +def test_apply_requires_root(monkeypatch, capsys) -> None: + monkeypatch.setattr(cli.os, "geteuid", lambda: 1000) + + result = cli.main(["apply"]) + + output = capsys.readouterr() + assert result == 1 + assert "must run as root" in output.err + + +def test_apply_prints_result(monkeypatch, capsys) -> None: + monkeypatch.setattr(cli.os, "geteuid", lambda: 0) + provider = object() + monkeypatch.setattr(cli, "AptProvider", lambda: provider) + plan = PackagePlan( + desired=("git", "thonny"), + install=("thonny",), + remove=("obsolete",), + unchanged=("git",), + ) + state = AgentState( + manifest_hash="abc123", + last_apply="2026-07-01T12:30:00Z", + packages=plan.desired, + overlay_enabled=False, + ready=False, + ) + + def apply_test_manifest(paths, package_provider): + assert paths.config == Path("/etc/nirj-agent/config.yaml") + assert package_provider is provider + return SimpleNamespace(plan=plan, state=state) + + monkeypatch.setattr(cli, "apply_manifest", apply_test_manifest) + + result = cli.main(["apply"]) + + output = capsys.readouterr() + assert result == 0 + assert '"manifest_hash": "abc123"' in output.out + assert '"thonny"' in output.out + assert '"obsolete"' in output.out + assert '"ready": false' in output.out + assert output.err == "" + + +def test_apply_reports_provider_failure(monkeypatch, capsys) -> None: + monkeypatch.setattr(cli.os, "geteuid", lambda: 0) + monkeypatch.setattr(cli, "AptProvider", lambda: object()) + + def fail_apply(**_kwargs): + raise AptProviderError("apt failed") + + monkeypatch.setattr(cli, "apply_manifest", fail_apply) + + result = cli.main(["apply"]) + + output = capsys.readouterr() + assert result == 1 + assert "Package application failed: apt failed" in output.err diff --git a/tests/test_cli_manifest.py b/tests/test_cli_manifest.py new file mode 100644 index 0000000..ede1ea3 --- /dev/null +++ b/tests/test_cli_manifest.py @@ -0,0 +1,55 @@ +from importlib import import_module +from pathlib import Path +from types import SimpleNamespace + +from nirj_agent.manifests.github import ManifestDownloadError + + +cli = import_module("nirj_agent.cli.main") + + +def test_manifest_refresh_prints_summary(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.setattr(cli, "load_config", lambda _path: object()) + monkeypatch.setattr(cli, "GitHubManifestClient", lambda: object()) + monkeypatch.setattr( + cli, + "refresh_manifest", + lambda **_kwargs: SimpleNamespace( + manifest=SimpleNamespace( + schema=1, + apt=SimpleNamespace(packages=("git", "thonny")), + ), + sha256="abc123", + source_url="https://example.test/manifest.yaml", + ), + ) + + result = cli.main(["--root", str(tmp_path), "manifest", "refresh"]) + + output = capsys.readouterr() + 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 output.err == "" + + +def test_manifest_refresh_reports_expected_error( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + monkeypatch.setattr(cli, "load_config", lambda _path: object()) + monkeypatch.setattr(cli, "GitHubManifestClient", lambda: object()) + + def fail_refresh(**_kwargs): + raise ManifestDownloadError("offline") + + monkeypatch.setattr(cli, "refresh_manifest", fail_refresh) + + result = cli.main(["--root", str(tmp_path), "manifest", "refresh"]) + + output = capsys.readouterr() + assert result == 1 + assert output.out == "" + assert "Manifest refresh failed: offline" in output.err diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py new file mode 100644 index 0000000..343bdd8 --- /dev/null +++ b/tests/test_cli_plan.py @@ -0,0 +1,53 @@ +from importlib import import_module +from pathlib import Path + +from nirj_agent.providers import AptProviderError +from nirj_agent.services.reconciliation import PackagePlan + + +cli = import_module("nirj_agent.cli.main") + + +def test_plan_prints_package_changes(tmp_path: Path, monkeypatch, capsys) -> None: + provider = object() + monkeypatch.setattr(cli, "AptProvider", lambda: provider) + + def create_test_plan(paths, package_provider): + assert paths.manifest_cache == ( + tmp_path / "var/lib/nirj-agent/manifests/current.yaml" + ) + assert package_provider is provider + return PackagePlan( + desired=("git", "thonny"), + install=("thonny",), + remove=("obsolete",), + unchanged=("git",), + ) + + monkeypatch.setattr(cli, "create_plan", create_test_plan) + + result = cli.main(["--root", str(tmp_path), "plan"]) + + output = capsys.readouterr() + assert result == 0 + assert '"changes_required": true' in output.out + assert '"install": [' in output.out + assert '"thonny"' in output.out + assert '"obsolete"' in output.out + assert output.err == "" + + +def test_plan_reports_provider_error(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.setattr(cli, "AptProvider", lambda: object()) + + def fail_plan(**_kwargs): + raise AptProviderError("dpkg-query failed") + + monkeypatch.setattr(cli, "create_plan", fail_plan) + + result = cli.main(["--root", str(tmp_path), "plan"]) + + output = capsys.readouterr() + assert result == 1 + assert output.out == "" + assert "Package planning failed: dpkg-query failed" in output.err diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..817479a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,135 @@ +from importlib import import_module +from pathlib import Path +from uuid import UUID + +import pytest +import yaml + +from nirj_agent.config import DeviceType, ConfigError, create_config, load_config + +cli = import_module("nirj_agent.cli.main") + + +def write_valid_config(path: Path, device_id: UUID) -> None: + path.write_text( + f""" +device: + id: {device_id} + asset_id: PI5-001 + type: pi5 +manifest: + source: + type: github + repo: NIRaspberryJam/nirj-infra + path: manifests/pi5.manifest.yaml + ref: main +overlay: + enabled: true +background: + enabled: true +""", + encoding="utf-8", + ) + + +def test_load_config(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + device_id = UUID("fc41017d-0937-49f2-a49d-a64164d9cc2e") + write_valid_config(path, device_id) + config = load_config(path) + assert config.device.id == device_id + assert config.device.asset_id == "PI5-001" + assert config.device.type == "pi5" + assert config.manifest.ref == "main" + assert config.overlay_enabled is True + assert config.background_enabled is True + + +def test_load_config_defaults_manifest_ref(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + device_id = UUID("fc41017d-0937-49f2-a49d-a64164d9cc2e") + write_valid_config(path, device_id) + contents = path.read_text(encoding="utf-8").replace(" ref: main\n", "") + path.write_text(contents, encoding="utf-8") + assert load_config(path).manifest.ref == "main" + + +def test_load_config_rejects_invalid_uuid(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + path.write_text( + """ +device: + id: not-a-uuid + asset_id: PI5-001 + type: pi5 +manifest: + source: + type: github + repo: NIRaspberryJam/nirj-infra + path: manifests/pi5.manifest.yaml +""", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="Invalid configuration"): + load_config(path) + + +def test_load_config_rejects_missing_required_field(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + path.write_text("device: {}\n", encoding="utf-8") + with pytest.raises(ConfigError, match="Invalid configuration"): + load_config(path) + + +def test_create_config_stores_full_uuid(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + config = create_config("PI5-001", DeviceType.PI5, path) + raw_config = yaml.safe_load(path.read_text(encoding="utf-8")) + stored_uuid = UUID(raw_config["device"]["id"]) + assert stored_uuid == config.device.id + assert stored_uuid.version == 4 + assert len(str(stored_uuid)) == 36 + assert config.device.asset_id == "PI5-001" + assert config.manifest.path == "manifests/pi5.manifest.yaml" + +def test_device_type_values() -> None: + assert [value.value for value in DeviceType] == [ + "pi5", + "lpt-lx", + "lpt-win", + ] + +def test_create_config_rejects_unknown_device_type(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + write_valid_config(path, UUID("fc41017d-0937-49f2-a49d-a64164d9cc2e")) + contents = path.read_text().replace("type: pi5", "type: toaster") + path.write_text(contents) + + with pytest.raises(ConfigError, match="toaster"): + load_config(path) + +def test_create_config_does_not_replace_existing_uuid(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + first = create_config("PI5-001", DeviceType.PI5, path) + + with pytest.raises(ConfigError, match="already exists"): + create_config("PI5-002", DeviceType.PI5, path) + + assert load_config(path).device.id == first.device.id + +def test_setup_creates_sandbox_config(tmp_path: Path, capsys) -> None: + result = cli.main([ + "--root", + str(tmp_path), + "setup", + "--asset-id", + "PI5-001", + "--device-type", + "pi5", + ]) + + config = load_config(tmp_path / "etc/nirj-agent/config.yaml") + + assert result == 0 + assert config.device.asset_id == "PI5-001" + assert config.device.type is DeviceType.PI5 diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..3aaac6b --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,80 @@ +from pathlib import Path + +import pytest + +from nirj_agent.manifests import ManifestError, load_manifest + + +def test_load_manifest(tmp_path: Path) -> None: + path = tmp_path / "manifest.yaml" + path.write_text( + """ +schema: 1 +apt: + enforce: true + packages: + - thonny + - scratch +overlay: + enabled: true +background: + enabled: false +""", + encoding="utf-8", + ) + manifest = load_manifest(path) + assert manifest.schema == 1 + assert manifest.apt.enforce is True + assert manifest.apt.packages == ("thonny", "scratch") + assert manifest.overlay_enabled is True + assert manifest.background_enabled is False + + +def test_manifest_removes_duplicate_packages(tmp_path: Path) -> None: + path = tmp_path / "manifest.yaml" + path.write_text( + """ +schema: 1 +apt: + packages: + - thonny + - scratch + - thonny +""", + encoding="utf-8", + ) + assert load_manifest(path).apt.packages == ("thonny", "scratch") + + +def test_manifest_defaults_optional_values(tmp_path: Path) -> None: + path = tmp_path / "manifest.yaml" + path.write_text("schema: 1\n", encoding="utf-8") + manifest = load_manifest(path) + assert manifest.apt.enforce is False + assert manifest.apt.packages == () + assert manifest.overlay_enabled is False + assert manifest.background_enabled is False + + +def test_manifest_rejects_unknown_schema(tmp_path: Path) -> None: + path = tmp_path / "manifest.yaml" + path.write_text("schema: 2\n", encoding="utf-8") + with pytest.raises(ManifestError, match="Unsupported manifest schema"): + load_manifest(path) + + +@pytest.mark.parametrize( + "packages", + ["thonny", "[thonny, '']", "[thonny, 42]"], +) +def test_manifest_rejects_invalid_packages( + tmp_path: Path, + packages: str, +) -> None: + path = tmp_path / "manifest.yaml" + path.write_text( + f"schema: 1\napt:\n packages: {packages}\n", + encoding="utf-8", + ) + with pytest.raises(ManifestError, match="apt.packages"): + load_manifest(path) diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..2a00540 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from nirj_agent.storage.paths import AgentPaths + + +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.maintenance_flag == Path( + "/boot/firmware/nirj-maintenance" + ) + + +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.maintenance_flag == ( + tmp_path / "boot/firmware/nirj-maintenance" + ) + assert paths.base_background == ( + tmp_path / "usr/share/nirj-agent/background-base.png" + ) diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..aa06074 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from nirj_agent.state import AgentState, load_state, save_state + + +def test_missing_state_returns_not_ready(tmp_path: Path) -> None: + state = load_state(tmp_path / "missing.yaml") + assert state == AgentState( + manifest_hash=None, + last_apply=None, + packages=(), + overlay_enabled=False, + ready=False, + ) + + +def test_save_and_load_state(tmp_path: Path) -> None: + path = tmp_path / "state.yaml" + expected = AgentState( + manifest_hash="abc123", + last_apply="2026-06-29T20:14:00Z", + packages=("thonny", "scratch"), + overlay_enabled=True, + ready=True, + ) + save_state(expected, path) + assert load_state(path) == expected diff --git a/tests/test_yaml_store.py b/tests/test_yaml_store.py new file mode 100644 index 0000000..f7ce434 --- /dev/null +++ b/tests/test_yaml_store.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import pytest + +from nirj_agent.storage.yaml import YamlStoreError, read_yaml, write_yaml + + +def test_write_and_read_yaml(tmp_path: Path) -> None: + path = tmp_path / "config.yaml" + write_yaml(path, {"device": {"asset_id": "PI5-001"}}) + assert read_yaml(path) == {"device": {"asset_id": "PI5-001"}} + + +def test_write_creates_missing_parent_directories(tmp_path: Path) -> None: + path = tmp_path / "nested" / "config.yaml" + write_yaml(path, {"ready": True}) + assert read_yaml(path) == {"ready": True} + + +def test_write_allows_existing_parent_directory(tmp_path: Path) -> None: + path = tmp_path / "state.yaml" + write_yaml(path, {"ready": True}) + write_yaml(path, {"ready": False}) + assert read_yaml(path) == {"ready": False} + + +def test_read_empty_yaml_returns_empty_mapping(tmp_path: Path) -> None: + path = tmp_path / "empty.yaml" + path.write_text("", encoding="utf-8") + assert read_yaml(path) == {} + + +def test_read_rejects_non_mapping_yaml(tmp_path: Path) -> None: + path = tmp_path / "list.yaml" + path.write_text("- thonny\n- scratch\n", encoding="utf-8") + with pytest.raises(YamlStoreError, match="must contain a YAML mapping"): + read_yaml(path) + + +def test_read_wraps_missing_file_error(tmp_path: Path) -> None: + path = tmp_path / "missing.yaml" + with pytest.raises(YamlStoreError, match="Unable to read"): + read_yaml(path) + + +def test_read_wraps_invalid_yaml(tmp_path: Path) -> None: + path = tmp_path / "invalid.yaml" + path.write_text("device: [\n", encoding="utf-8") + with pytest.raises(YamlStoreError, match="Unable to read"): + read_yaml(path)