Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ when OverlayFS is active, it requests a writable reboot before persisting those
files and then restores the configured overlay state. Raspberry Pi
Desktop/PCManFM, XFCE, and GNOME are supported.

Python packages are installed into /data/nirj/python-venv. Package versions
must be exact. Packages are installed from PyPI as wheels; direct URLs, Git
repositories, editable installs, and arbitrary pip options are not accepted.

Once that environment exists, the agent configures the existing `/home/jam`
account to use it in Bash and desktop login sessions, and sets VS Code's
`python.defaultInterpreterPath` to `/data/nirj/python-venv/bin/python`.
Log out and back in after the first application to pick up the session PATH.
The Python extension must be installed in VS Code; workspaces with an already
selected interpreter may need **Python: Select Interpreter** once. Explicitly
activated virtual environments take precedence in new shells. System Python
at `/usr/bin/python3` is unchanged. Existing shell configuration, VS Code
settings (including comments), and file ownership are preserved.

Package operation failures are logged and recorded in the agent state's
`errors` list. Independent APT/Python operations continue, and shortcuts are
only created for applications confirmed installed. A partial update leaves
`ready: false` and does not promote the target manifest to current. The agent
still starts, with the root left writable if an update required disabling
OverlayFS. Retry with `nirj-agent update apply` or restart the service after
fixing the package/repository issue; there is no periodic retry loop.
Explicit apply commands return a failure exit status for partial updates.

The manifest can also place agent-managed application launchers on the `jam`
user's desktop. Each shortcut must include its corresponding APT package:

Expand All @@ -108,7 +131,11 @@ schema: 1
apt:
packages:
- code
- python3-venv
- sonic-pi
python:
packages:
jamkit: "0.1.0"
desktop:
shortcuts:
- vscode
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ requires-python = ">=3.11"
dependencies = [
"Pillow>=11.0,<12",
"PyYAML>=6.0,<7",
"packaging>=24,<27",
]

[project.optional-dependencies]
Expand Down
64 changes: 47 additions & 17 deletions src/nirj_agent/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@
)
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.providers import (
AptProvider,
AptProviderError,
PipProvider,
PipProviderError,
)
from nirj_agent.services.apply import ApplyError, apply_manifest
from nirj_agent.services.boot import boot_prep
from nirj_agent.services.desktop import (
Expand Down Expand Up @@ -49,6 +54,7 @@
EXPECTED_ERRORS = (
ApplyError,
AptProviderError,
PipProviderError,
ConfigError,
FileStoreError,
JsonStoreError,
Expand Down Expand Up @@ -180,6 +186,7 @@ def main(argv: Sequence[str] | None = None) -> int:
paths=paths,
client=GitHubManifestClient(),
package_provider=AptProvider(),
python_provider=PipProvider(paths.python_environment),
overlay=OverlayManager(),
)
print(json.dumps(asdict(result), indent=2))
Expand All @@ -197,9 +204,12 @@ def main(argv: Sequence[str] | None = None) -> int:
paths=paths,
client=GitHubManifestClient(),
package_provider=AptProvider(),
python_provider=PipProvider(paths.python_environment),
overlay=OverlayManager(),
)
print(json.dumps(asdict(result), indent=2))
if result.action == "update_failed":
return 1
return 194 if result.reboot_requested else 0

if args.command == "overlay":
Expand All @@ -225,26 +235,45 @@ def main(argv: Sequence[str] | None = None) -> int:
return _watch_wallpaper(paths)

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))
plan = create_plan(
paths=paths,
package_provider=AptProvider(),
python_provider=PipProvider(paths.python_environment),
)

print(
json.dumps(
{
"changes_required": plan.changes_required,
"apt": asdict(plan.apt),
"python": asdict(plan.python),
},
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))
result = apply_manifest(
paths=paths,
package_provider=AptProvider(),
python_provider=PipProvider(paths.python_environment),
)
print(
json.dumps(
{
"manifest_hash": result.state.manifest_hash,
"last_apply": result.state.last_apply,
"apt": asdict(result.plan.apt),
"python": asdict(result.plan.python),
"ready": result.state.ready,
},
indent=2,
)
)

return 0

if args.command == "manifest" and args.manifest_command == "refresh":
Expand All @@ -258,7 +287,8 @@ def main(argv: Sequence[str] | None = None) -> int:
"sha256": document.sha256,
"source": document.source_url,
"cache": str(paths.manifest_cache),
"packages": len(document.manifest.apt.packages),
"apt_packages": len(document.manifest.apt.packages),
"python_packages": len(document.manifest.python.packages),
}, indent=2))
return 0
except EXPECTED_ERRORS as exc:
Expand Down
2 changes: 2 additions & 0 deletions src/nirj_agent/manifests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
DesktopManifest,
Manifest,
ManifestDocument,
PythonManifest,
)
from .parser import ManifestError, load_manifest, parse_manifest

Expand All @@ -16,4 +17,5 @@
"SUPPORTED_DESKTOP_SHORTCUTS",
"load_manifest",
"parse_manifest",
"PythonManifest",
]
7 changes: 7 additions & 0 deletions src/nirj_agent/manifests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ class AptManifest:
packages: tuple[str, ...]


@dataclass(frozen=True)
class PythonManifest:
packages: tuple[tuple[str, str], ...]


@dataclass(frozen=True)
class DesktopManifest:
shortcuts: tuple[str, ...]
Expand All @@ -19,10 +24,12 @@ class DesktopManifest:
class Manifest:
schema: int
apt: AptManifest
python: PythonManifest
desktop: DesktopManifest
overlay_enabled: bool
background_enabled: bool


@dataclass(frozen=True)
class ManifestDocument:
manifest: Manifest
Expand Down
62 changes: 58 additions & 4 deletions src/nirj_agent/manifests/parser.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import re
from collections.abc import Mapping
from pathlib import Path
import re
from typing import Any

import yaml
from packaging.utils import InvalidName, canonicalize_name
from packaging.version import InvalidVersion, Version

from .models import (
SUPPORTED_DESKTOP_SHORTCUTS,
AptManifest,
DesktopManifest,
Manifest,
PythonManifest,
)


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 = "<memory>") -> Manifest:
try:
text = content.decode("utf-8")
Expand All @@ -35,6 +40,7 @@ def parse_manifest(content: bytes, source: str = "<memory>") -> Manifest:

return manifest_from_mapping(data, source)


def load_manifest(path: Path) -> Manifest:
try:
content = path.read_bytes()
Expand All @@ -43,8 +49,7 @@ def load_manifest(path: Path) -> Manifest:

return parse_manifest(content, str(path))




def manifest_from_mapping(data: object, source: str) -> Manifest:
root = require_mapping(data, "manifest", source)

Expand All @@ -59,7 +64,7 @@ def manifest_from_mapping(data: object, source: str) -> Manifest:
raise ManifestError(
f"Unsupported manifest schema from {source}: {schema}"
)

apt = require_mapping(root.get("apt", {}), "apt", source)
packages = apt.get("packages", [])

Expand All @@ -71,6 +76,51 @@ def manifest_from_mapping(data: object, source: str) -> Manifest:
f"apt.packages in {source} must be a list of package names"
)

python = require_mapping(root.get("python", {}), "python", source)
raw_python_packages = require_mapping(
python.get("packages", {}),
"python.packages",
source,
)

python_packages: dict[str, str] = {}

for raw_name, raw_version in raw_python_packages.items():
if not isinstance(raw_name, str) or not raw_name.strip():
raise ManifestError(
f"python.packages in {source} contains an invalid package name"
)

if not isinstance(raw_version, str) or not raw_version.strip():
raise ManifestError(
f"Python package {raw_name!r} in {source} must have an "
"exact version"
)

try:
name = canonicalize_name(raw_name, validate=True)
except InvalidName as exc:
raise ManifestError(
f"python.packages in {source} contains invalid package "
f"name {raw_name!r}"
) from exc

try:
version = str(Version(raw_version))
except InvalidVersion as exc:
raise ManifestError(
f"Python package {raw_name!r} in {source} has invalid "
f"version {raw_version!r}"
) from exc

if name in python_packages:
raise ManifestError(
f"python.packages in {source} contains duplicate normalized "
f"package name {name!r}"
)

python_packages[name] = version

desktop = require_mapping(root.get("desktop", {}), "desktop", source)
shortcuts = desktop.get("shortcuts", [])

Expand Down Expand Up @@ -103,6 +153,9 @@ def manifest_from_mapping(data: object, source: str) -> Manifest:
enforce=read_boolean(apt, "enforce", False, source),
packages=tuple(dict.fromkeys(packages)),
),
python=PythonManifest(
packages=tuple(sorted(python_packages.items())),
),
desktop=DesktopManifest(
shortcuts=tuple(dict.fromkeys(shortcuts)),
),
Expand All @@ -114,6 +167,7 @@ def manifest_from_mapping(data: object, source: str) -> Manifest:
),
)


def require_mapping(
value: object,
field: str,
Expand Down
8 changes: 7 additions & 1 deletion src/nirj_agent/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""System integration providers."""

from .apt import AptProvider, AptProviderError
from .pip import PipProvider, PipProviderError

__all__ = ["AptProvider", "AptProviderError"]
__all__ = [
"AptProvider",
"AptProviderError",
"PipProvider",
"PipProviderError",
]
Loading
Loading