diff --git a/README.md b/README.md index 9c80330..46a23d1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/nirj_agent/cli/main.py b/src/nirj_agent/cli/main.py index a10a7ee..6210b6a 100644 --- a/src/nirj_agent/cli/main.py +++ b/src/nirj_agent/cli/main.py @@ -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 diff --git a/src/nirj_agent/services/boot.py b/src/nirj_agent/services/boot.py index 6101110..17558cd 100644 --- a/src/nirj_agent/services/boot.py +++ b/src/nirj_agent/services/boot.py @@ -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: @@ -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( @@ -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( @@ -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() diff --git a/src/nirj_agent/storage/paths.py b/src/nirj_agent/storage/paths.py index f131dc6..64c2ee3 100644 --- a/src/nirj_agent/storage/paths.py +++ b/src/nirj_agent/storage/paths.py @@ -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 @@ -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"), @@ -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", diff --git a/tests/services/test_boot.py b/tests/services/test_boot.py index b4e58db..50a33fd 100644 --- a/tests/services/test_boot.py +++ b/tests/services/test_boot.py @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py index 5ee8fbe..5d132ec 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ from importlib import import_module +from types import SimpleNamespace from uuid import UUID from nirj_agent.config import ( @@ -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") @@ -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"] diff --git a/tests/test_paths.py b/tests/test_paths.py index a12cbdb..10a941a 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -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" @@ -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 == (