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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ reboots. Failures persist as `failed` with an error in `update.json`.

Overlay activity is determined from `findmnt -n -o FSTYPE /`; configuration
changes use Raspberry Pi OS's `raspi-config nonint` interface and always
require a reboot.
require a reboot. `overlay disable` creates
`/data/nirj/state/overlay-disabled-once`, which makes the next `boot-prep`
skip OverlayFS manifest enforcement. The flag is consumed on that boot, so a
later boot restores the manifest's configured OverlayFS state.

Useful commands:

Expand Down
8 changes: 8 additions & 0 deletions src/nirj_agent/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0
if not _require_root(args.root, "Overlay changes"):
return 1
if args.overlay_command == "disable":
paths.overlay_disabled_once_flag.parent.mkdir(
parents=True,
exist_ok=True,
)
paths.overlay_disabled_once_flag.touch()
elif args.overlay_command == "enable":
paths.overlay_disabled_once_flag.unlink(missing_ok=True)
getattr(manager, args.overlay_command)()
manager.sync_and_reboot()
return 0
Expand Down
37 changes: 33 additions & 4 deletions src/nirj_agent/services/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,22 @@ def boot_prep(
update = load_update_state(paths.update_state)
target_hash = update.target_hash
overlay_status = overlay.status()
overlay_disabled_once = paths.overlay_disabled_once_flag.exists()
overlay_desired = config.overlay_enabled and not overlay_disabled_once

if update.state is UpdatePhase.PENDING:
if overlay_status.active:
overlay.disable()
overlay.sync_and_reboot()
return BootPrepResult("waiting_for_writable_boot", True)
return _apply_and_restore(paths, config.overlay_enabled, package_provider, overlay)
result = _apply_and_restore(
paths,
overlay_desired,
package_provider,
overlay,
)
_consume_overlay_disabled_once(paths, overlay_disabled_once)
return result

check = check_for_update(paths, client, persist_target=True)
if check.update_available:
Expand All @@ -55,7 +64,14 @@ def boot_prep(
overlay.disable()
overlay.sync_and_reboot()
return BootPrepResult("update_pending", True)
return _apply_and_restore(paths, config.overlay_enabled, package_provider, overlay)
result = _apply_and_restore(
paths,
overlay_desired,
package_provider,
overlay,
)
_consume_overlay_disabled_once(paths, overlay_disabled_once)
return result

set_wallpaper_state(paths, "ready")
state = replace(
Expand All @@ -67,14 +83,19 @@ def boot_prep(
save_state(state, paths.state)
save_update_state(UpdateState(), paths.update_state)

if config.overlay_enabled and not overlay_status.active:
if overlay_desired and not overlay_status.active:
overlay.enable()
overlay.sync_and_reboot()
return BootPrepResult("enabling_overlay", True)
if not config.overlay_enabled and overlay_status.active:
if (
not overlay_desired
and not overlay_disabled_once
and overlay_status.active
):
overlay.disable()
overlay.sync_and_reboot()
return BootPrepResult("disabling_overlay", True)
_consume_overlay_disabled_once(paths, overlay_disabled_once)
return BootPrepResult("ready", False)
except Exception as exc:
save_update_state(
Expand Down Expand Up @@ -104,3 +125,11 @@ def _apply_and_restore(
overlay.sync_and_reboot()
return BootPrepResult("update_applied", True)
return BootPrepResult("update_applied", False)


def _consume_overlay_disabled_once(
paths: AgentPaths,
overlay_disabled_once: bool,
) -> None:
if overlay_disabled_once:
paths.overlay_disabled_once_flag.unlink()
7 changes: 7 additions & 0 deletions src/nirj_agent/storage/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class AgentPaths:
current_manifest: Path
target_manifest: Path
update_state: Path
overlay_disabled_once_flag: Path
apply_lock: Path
generated_dir: Path
maintenance_flag: Path
Expand All @@ -26,6 +27,9 @@ def system(cls) -> "AgentPaths":
current_manifest=Path("/data/nirj/state/current-manifest.json"),
target_manifest=Path("/data/nirj/state/target-manifest.json"),
update_state=Path("/data/nirj/state/update.json"),
overlay_disabled_once_flag=Path(
"/data/nirj/state/overlay-disabled-once"
),
apply_lock=Path("/data/nirj/state/apply.lock"),
generated_dir=Path("/data/nirj/cache/generated"),
maintenance_flag=Path("/boot/firmware/nirj-maintenance"),
Expand All @@ -45,6 +49,9 @@ def sandbox(cls, root: Path) -> "AgentPaths":
current_manifest=data_root / "state/current-manifest.json",
target_manifest=data_root / "state/target-manifest.json",
update_state=data_root / "state/update.json",
overlay_disabled_once_flag=(
data_root / "state/overlay-disabled-once"
),
apply_lock=data_root / "state/apply.lock",
generated_dir=data_root / "cache/generated",
maintenance_flag=root / "boot/firmware/nirj-maintenance",
Expand Down
53 changes: 53 additions & 0 deletions tests/services/test_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,56 @@ def test_writable_boot_applies_target_and_reenables_overlay(tmp_path) -> None:
assert overlay.events == ["enable", "reboot"]
assert paths.current_manifest.read_bytes() == MANIFEST
assert load_update_state(paths.update_state).state is UpdatePhase.NORMAL


def test_overlay_disable_flag_skips_manifest_for_one_boot(tmp_path) -> None:
paths = prepare(tmp_path)
paths.current_manifest.parent.mkdir(parents=True, exist_ok=True)
paths.current_manifest.write_bytes(MANIFEST)
paths.overlay_disabled_once_flag.touch()
overlay = Overlay(active=False)

first_result = boot_prep(paths, Client(), Packages(), overlay)

assert first_result.action == "ready"
assert first_result.reboot_requested is False
assert overlay.events == []
assert not paths.overlay_disabled_once_flag.exists()

second_result = boot_prep(paths, Client(), Packages(), overlay)

assert second_result.action == "enabling_overlay"
assert second_result.reboot_requested is True
assert overlay.events == ["enable", "reboot"]


def test_overlay_disable_flag_suppresses_restore_after_update(tmp_path) -> None:
paths = prepare(tmp_path)
paths.target_manifest.parent.mkdir(parents=True, exist_ok=True)
paths.target_manifest.write_bytes(MANIFEST)
paths.overlay_disabled_once_flag.touch()
save_update_state(UpdateState(UpdatePhase.PENDING, "target"), paths.update_state)
overlay = Overlay(active=False)

result = boot_prep(paths, Client(), Packages(), overlay)

assert result.action == "update_applied"
assert result.reboot_requested is False
assert overlay.events == []
assert not paths.overlay_disabled_once_flag.exists()


def test_overlay_disable_flag_survives_intermediate_reboot(tmp_path) -> None:
paths = prepare(tmp_path)
paths.target_manifest.parent.mkdir(parents=True, exist_ok=True)
paths.target_manifest.write_bytes(MANIFEST)
paths.overlay_disabled_once_flag.touch()
save_update_state(UpdateState(UpdatePhase.PENDING, "target"), paths.update_state)
overlay = Overlay(active=True)

result = boot_prep(paths, Client(), Packages(), overlay)

assert result.action == "waiting_for_writable_boot"
assert result.reboot_requested is True
assert overlay.events == ["disable", "reboot"]
assert paths.overlay_disabled_once_flag.exists()
20 changes: 20 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from importlib import import_module
from types import SimpleNamespace
from uuid import UUID

from nirj_agent.config import (
Expand All @@ -8,6 +9,7 @@
ManifestSource,
)
from nirj_agent.state import AgentState
from nirj_agent.storage.paths import AgentPaths

cli = import_module("nirj_agent.cli.main")

Expand Down Expand Up @@ -104,3 +106,21 @@ def run_test_agent(**kwargs) -> None:
assert received["paths"] == cli.AgentPaths.system()
assert isinstance(received["stop_event"], cli.Event)
assert set(received) == {"paths", "stop_event"}


def test_overlay_disable_sets_one_boot_flag(tmp_path, monkeypatch) -> None:
paths = AgentPaths.sandbox(tmp_path)
events = []
manager = SimpleNamespace(
disable=lambda: events.append("disable"),
sync_and_reboot=lambda: events.append("reboot"),
)
monkeypatch.setattr(cli.AgentPaths, "system", lambda: paths)
monkeypatch.setattr(cli.os, "geteuid", lambda: 0)
monkeypatch.setattr(cli, "OverlayManager", lambda: manager)

result = cli.main(["overlay", "disable"])

assert result == 0
assert paths.overlay_disabled_once_flag.exists()
assert events == ["disable", "reboot"]
6 changes: 6 additions & 0 deletions tests/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ def test_system_paths_use_expected_locations() -> None:
assert paths.current_manifest == Path("/data/nirj/state/current-manifest.json")
assert paths.target_manifest == Path("/data/nirj/state/target-manifest.json")
assert paths.update_state == Path("/data/nirj/state/update.json")
assert paths.overlay_disabled_once_flag == Path(
"/data/nirj/state/overlay-disabled-once"
)
assert paths.apply_lock == Path("/data/nirj/state/apply.lock")
assert paths.maintenance_flag == Path(
"/boot/firmware/nirj-maintenance"
Expand All @@ -23,6 +26,9 @@ def test_sandbox_paths_stay_below_root(tmp_path: Path) -> None:

assert paths.config == tmp_path / "data/nirj/config/config.yaml"
assert paths.state == tmp_path / "data/nirj/state/state.yaml"
assert paths.overlay_disabled_once_flag == (
tmp_path / "data/nirj/state/overlay-disabled-once"
)
assert paths.apply_lock == tmp_path / "data/nirj/state/apply.lock"
assert paths.generated_dir == tmp_path / "data/nirj/cache/generated"
assert paths.maintenance_flag == (
Expand Down
Loading