diff --git a/README.md b/README.md index 6e3eefda..9991207b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ shaped the way it is, what broke before, which invariants matter, and how workflows cross files and services. The wiki is plain markdown in your repo, indexed locally, and reviewed in Git like any other code change. -**Supported today:** macOS with Codex or Claude Code. Requires Python 3.12+. +**Supported today:** macOS and Linux (systemd) with Codex or Claude Code. Requires Python 3.12+. ## Quickstart @@ -133,8 +133,9 @@ codealmanac setup --yes codealmanac setup --yes --runner claude ``` -Setup installs agent instructions for your chosen tools and three local macOS -`launchd` jobs. Nothing runs in the cloud. +Setup installs agent instructions for your chosen tools and three local +scheduled jobs (`launchd` on macOS, systemd user timers on Linux). Nothing +runs in the cloud. | Job | Default schedule | What it does | | --- | ---: | --- | @@ -244,8 +245,9 @@ structure. updates are skipped when an update would be unsafe, such as while lifecycle work is active. -Automation is implemented with local macOS `launchd` jobs, not a hosted service -or cloud sync. Logs are stored under `~/.codealmanac/logs/`. +Automation is implemented with local scheduler jobs — `launchd` on macOS, +systemd user timers on Linux — not a hosted service or cloud sync. Logs are +stored under `~/.codealmanac/logs/`. ```bash # See installed schedules @@ -261,7 +263,8 @@ codealmanac config set automation.sync.enabled false codealmanac config set automation.sync.enabled true ``` -`config set` updates the user TOML and immediately makes launchd match. If you +`config set` updates the user TOML and immediately makes the scheduler match. +If you edit the TOML directly, run `codealmanac config apply` afterward. Automation creates individual background runs. Inspect those runs separately @@ -392,7 +395,8 @@ every = "24h" CLI flags still win over config. Use `codealmanac config set ` for normal changes. It applies -automation changes to launchd immediately. Direct file edits are supported but +automation changes to the scheduler immediately. Direct file edits are +supported but must be followed by: ```bash diff --git a/almanac/architecture/setup/automation-and-update.md b/almanac/architecture/setup/automation-and-update.md index 8524cafb..8f78e09c 100644 --- a/almanac/architecture/setup/automation-and-update.md +++ b/almanac/architecture/setup/automation-and-update.md @@ -30,6 +30,10 @@ sources: type: file path: src/codealmanac/integrations/automation/scheduler/launchd.py note: macOS launchd scheduler adapter. + - id: systemd + type: file + path: src/codealmanac/integrations/automation/scheduler/systemd.py + note: Linux systemd user-timer scheduler adapter. - id: updates type: file path: src/codealmanac/services/updates/service.py @@ -50,7 +54,7 @@ sources: # Setup Automation And Update -Setup, automation, and update form CodeAlmanac's machine-level maintenance layer. `setup` installs local agent instructions and writes one user configuration update. The config service then makes launchd match that saved automation policy. `automation` owns recurring launchd jobs, while `update` owns package-manager upgrades for the installed CLI [@setup_service][@automation_service][@updates]. +Setup, automation, and update form CodeAlmanac's machine-level maintenance layer. `setup` installs local agent instructions and writes one user configuration update. The config service then makes the platform scheduler match that saved automation policy. `automation` owns recurring scheduler jobs, while `update` owns package-manager upgrades for the installed CLI [@setup_service][@automation_service][@updates]. The area matters because it is local-only product infrastructure. Scheduled work runs local CodeAlmanac task entrypoints for sync, Garden, and update; it does not connect to a hosted service or perform cloud capture [@live_agreement]. Runtime state and scheduler logs belong under the user's machine state, while repository wiki source remains under `almanac/` [@live_agreement]. @@ -68,9 +72,9 @@ Setup's automation policy lives outside the service. The default tasks are sync, The job factory gives each task concrete local execution details. The resolved `codealmanac` executable runs `sync`, `update --scheduled`, or `__garden-scheduler` [@automation_jobs]. Intervals come from saved user configuration; defaults are 5 hours, 4 hours, and 24 hours [@automation_jobs]. -The macOS implementation writes launchd plists under `~/Library/LaunchAgents`, creates stdout and stderr log directories, bootouts any existing job, bootstraps the new job, and reads status back from launchd [@launchd]. The generated plist contains the label, program arguments, start interval, environment variables, `RunAtLoad`, and log paths [@launchd]. This keeps the service boundary scheduler-neutral while the adapter owns launchd mechanics. +The macOS implementation writes launchd plists under `~/Library/LaunchAgents`, creates stdout and stderr log directories, bootouts any existing job, bootstraps the new job, and reads status back from launchd [@launchd]. The generated plist contains the label, program arguments, start interval, environment variables, `RunAtLoad`, and log paths [@launchd]. The Linux implementation writes a `.timer` and `.service` unit pair under `~/.config/systemd/user`, enables and restarts the timer through `systemctl --user`, and reads status back from `systemctl show` [@systemd]. The timer uses `OnActiveSec=0` for launchd `RunAtLoad` parity and `OnUnitActiveSec` for the interval [@systemd]. This keeps the service boundary scheduler-neutral while each adapter owns its scheduler's mechanics. -The default application wiring is launchd-backed. `create_services` constructs `AutomationService` with `LaunchdSchedulerAdapter`, then injects it into the config service. The adapter shells out to `launchctl` for install, uninstall, and status checks [@app][@launchd]. Config reconciliation is macOS-specific until another scheduler adapter is wired. +The default application wiring selects the scheduler by platform. `create_services` constructs `AutomationService` with `default_scheduler_adapter()`, which returns the systemd adapter on Linux and the launchd adapter elsewhere, then injects it into the config service [@app]. The launchd adapter shells out to `launchctl`; the systemd adapter runs `systemctl --user` through an injectable command runner [@launchd][@systemd]. ## Update Safety diff --git a/docs/plans/2026-07-13-linux-systemd-scheduler.md b/docs/plans/2026-07-13-linux-systemd-scheduler.md new file mode 100644 index 00000000..b4cddcb1 --- /dev/null +++ b/docs/plans/2026-07-13-linux-systemd-scheduler.md @@ -0,0 +1,244 @@ +# Linux systemd scheduler + +Add Linux support to scheduled automation by implementing a systemd `--user` +timer scheduler adapter and selecting the scheduler adapter by platform. +Today `setup` and `automation` only work on macOS because the composition root +hardcodes `LaunchdSchedulerAdapter` and the domain model leaks launchd +vocabulary (`plist_path`). + +## Scope + +- A `SystemdSchedulerAdapter` under `integrations/automation/scheduler/` + implementing the existing `SchedulerAdapter` port with `systemctl --user` + timers. +- A `default_scheduler_adapter()` factory that returns the systemd adapter on + Linux and the launchd adapter elsewhere, wired into `app.py`. The + `adapters.scheduler` injection path is unchanged. +- De-launchd the automation domain model: `plist_path` becomes + `manifest_path`, `plist_path_for` becomes `manifest_path_for` and computes + the platform-correct manifest location. `launch_path` stops leaking + macOS-only PATH fallbacks onto Linux. +- Render and docs updates: provider-neutral `automation status` labels, + README support claim, and the stale "macOS-specific" line in the almanac + automation page. + +## Out of scope + +- Windows support (no scheduler adapter, unchanged behavior). +- cron support (rejected alternative, below). +- Non-systemd Linux init systems. `systemctl --user` failures surface the + same way a missing `launchctl` does on macOS: as command errors, not + crashes. +- Any change to what the scheduled jobs run or when they run by default. + +## Design + +### Platform-selecting factory + +`integrations/automation/scheduler/__init__.py` gains: + +```python +def default_scheduler_adapter() -> SchedulerAdapter: + if sys.platform.startswith("linux"): + return SystemdSchedulerAdapter() + return LaunchdSchedulerAdapter() +``` + +`create_services` in `app.py` uses it instead of the hardcoded +`LaunchdSchedulerAdapter()`. Integrations already implement service-owned +ports, so the platform branch lives in the integration layer, not in the +service. Non-Linux, non-Darwin platforms keep the launchd adapter and fail +the same way they do today (missing `launchctl` binary), which keeps the +factory honest without inventing an unsupported-platform error path. + +### `plist_path` becomes `manifest_path` + +`ScheduledJob`, `ScheduledJobStatus`, and `AutomationTaskApplyResult` rename +`plist_path` to `manifest_path`. `jobs.plist_path_for` becomes +`manifest_path_for(task, home, platform=None)`: + +- macOS: `~/Library/LaunchAgents/{label}.plist` (unchanged). +- Linux: `~/.config/systemd/user/{label}.timer`. The `.timer` unit is the + primary manifest; the systemd adapter derives the sibling `.service` path + from it. + +**Deliberate output change:** `automation status --json` (and the setup / +config JSON payloads that embed apply results) now emit `manifest_path` +instead of `plist_path`, and the human status output says `manifest:` and +`scheduler loaded:` instead of `plist:` and `launchd loaded:`. This is a +pre-1.0 contract fix, not an accident: a provider-specific field name in the +scheduler-neutral service model was a leak (the repo's honest-modules rule), +and it becomes actively wrong the moment a second scheduler exists. Callers +of the JSON contract must rename one key. + +`launch_path` keeps the macOS PATH byte-identical and selects Linux +fallbacks (`/usr/local/bin`, `/usr/bin`, `/bin`, `/usr/sbin`, `/sbin`) on +Linux. `LAUNCHD_FALLBACK_PATHS` is renamed to platform-named constants in +`defaults.py`. The home-relative additions (`~/.local/bin`, `~/.bun/bin`) +apply on both platforms. + +The `platform` parameter defaults to `sys.platform` so production callers +never pass it; tests pin `"darwin"` and `"linux"` explicitly to cover both +mappings on any CI host. + +### `SystemdSchedulerAdapter` + +Mirrors the launchd adapter's shape (install/uninstall/status plus module +helper functions) with one improvement: the `systemctl` invocation goes +through an injectable command runner (`run_command` constructor argument +defaulting to a subprocess-backed function). The launchd adapter shells out +directly, which forces tests to monkeypatch `subprocess.run`; the new +adapter should not repeat that. The runner keeps the launchd adapter's +graceful `OSError` handling: a missing `systemctl` binary becomes a failed +`CompletedProcess`, not a crash. + +- `install(job)`: write `{label}.service` and `{label}.timer` under + `~/.config/systemd/user/`, create log directories, then `daemon-reload`, + `enable {label}.timer` (boot persistence), `restart {label}.timer` + (activate now and re-trigger on reinstall, mirroring launchd's + bootout/bootstrap cycle), and return `status(job)`. +- `uninstall(job)`: `disable --now {label}.timer`, `stop {label}.service` + (parity with launchd `bootout` terminating a running job), remove both + unit files, `daemon-reload`, then `reset-failed` for both units. + The `reset-failed` matters: stopping the timer can fire one last trigger + that the service stop then TERM-kills, which would otherwise leave a + residual `not-found failed` unit in the user manager (observed during the + real smoke test). Unit-not-found results are tolerated; other `systemctl` + failures raise `ExecutionFailed` before any file is removed, matching the + launchd adapter. Returns true when a unit file existed or the timer was + actually disabled. +- `status(job)`: `installed` = timer file exists; parse + `systemctl --user show` properties — timer `LoadState` (loaded), + service `ActiveState` (state), `ExecMainStatus`/`ExecMainExitTimestampMonotonic` + (last exit code, only once the service has actually exited), `MainPID`. + The interval is read back from the timer file's `OnUnitActiveSec=` line, + matching how the launchd adapter reads `StartInterval` from the plist. + +Unit file shape: + +```ini +# {label}.service +[Unit] +Description=CodeAlmanac {task} automation + +[Service] +Type=oneshot +ExecStart=/path/to/codealmanac sync +Environment="PATH=..." +StandardOutput=append:~/.codealmanac/logs/sync.out.log +StandardError=append:~/.codealmanac/logs/sync.err.log + +# {label}.timer +[Unit] +Description=CodeAlmanac {task} automation timer + +[Timer] +OnActiveSec=0 +OnUnitActiveSec={interval seconds} +Unit={label}.service + +[Install] +WantedBy=timers.target +``` + +`ExecStart` and `Environment` values are quoted and `%`-escaped per +systemd's unit-file syntax so paths with spaces survive. + +**RunAtLoad parity:** launchd runs the job at load (`RunAtLoad=True`) and +then every `StartInterval`. The systemd equivalent chosen here is +`OnActiveSec=0` + `OnUnitActiveSec={interval}`: the timer fires immediately +when activated (install, login, boot) and then keeps an interval cadence +from each run. `OnBootSec` would be redundant with `OnActiveSec=0`. +`Persistent=true` is deliberately omitted: per systemd.timer(5) it only +affects `OnCalendar=` timers, so writing it into a monotonic timer would be +inert configuration — the kind of decorative setting the honest-modules rule +exists to keep out. + +**run_count stays `None` on Linux:** systemd does not track a trigger count +(`NRestarts` counts `Restart=` restarts, which is always 0 for a oneshot and +would misreport "runs: 0" forever). Reporting nothing is more honest than +reporting a wrong number. macOS keeps launchd's `runs` value. + +### Rejected alternative: cron + +Cron is more portable but strictly worse for this job shape: no per-job +environment blocks without shell wrapping, no native append-to-log-file +redirection (needs shell), no run-at-install semantics, no status/last-exit +introspection (the `status` verb would have to parse log files), and +crontab editing is a single shared file rather than per-job manifests. The +systemd user manager is present on every mainstream Linux distribution this +project can realistically claim support for, and its unit files map +one-to-one onto the existing `ScheduledJob` model. + +## File changes + +- `services/automation/models.py` — `plist_path` → `manifest_path` (3 models). +- `services/automation/jobs.py` — `manifest_path_for`, per-platform + `launch_path` fallbacks. +- `services/automation/defaults.py` — platform-named PATH fallback constants. +- `services/automation/service.py`, `services/automation/ports.py` — field + rename, docstring. +- `integrations/automation/scheduler/systemd.py` — new adapter. +- `integrations/automation/scheduler/__init__.py`, + `integrations/automation/__init__.py` — exports + factory. +- `app.py` — use `default_scheduler_adapter()`. +- `cli/render/automation.py` — `manifest:` / `scheduler loaded:` labels. +- `tests/test_automation_service.py` — rename, platform-pinned + `manifest_path_for` tests, systemd adapter tests (fake runner). +- `tests/test_cli.py`, `tests/test_config_service.py`, + `tests/test_setup_service.py`, `tests/test_architecture.py` — rename and + fragment-list updates. +- `README.md`, `almanac/architecture/setup/automation-and-update.md` — Linux + support claim, generalized wording. + +## Test plan + +- Unit: `manifest_path_for` on `darwin` and `linux`; `launch_path` fallback + selection per platform (macOS string byte-identical to today's). +- Unit: systemd unit-file rendering (ExecStart quoting, PATH environment, + log redirection, timer interval), install command sequence + (`daemon-reload` then `enable --now`), uninstall semantics (present / + not-found / real failure preserves files), `status` parsing from fake + `systemctl show` output including never-run and mid-run shapes, + `default_scheduler_adapter` platform selection. +- Existing suite: all launchd, service, CLI, config, setup tests updated for + the rename must pass unchanged in behavior. +- Real smoke on a Linux host with a running user manager: install a job with + a harmless command and long interval into the real + `~/.config/systemd/user`, verify `list-timers`/`is-enabled`, `status()` + fields, then uninstall and verify nothing is left. +- Gates: `uv run pytest`, `uv run ruff check .`, `uv build`. + +## Read before coding + +- `src/codealmanac/integrations/automation/scheduler/launchd.py` — the shape + to mirror, including `ExecutionFailed` and not-found tolerance. +- `src/codealmanac/services/automation/jobs.py` — job construction and PATH + assembly. +- `tests/test_automation_service.py` — fake scheduler style and launchd + adapter test conventions. +- `tests/test_architecture.py` + (`test_automation_service_keeps_selection_and_job_construction_boundaries`) + — fragment lists that pin the service/jobs split. +- `docs/reference/cosmic-python/chapter_13_dependency_injection.md` — the + book's "_Composition Root_ (a bootstrap script to you and me)" with + "manual DI" is why the platform switch is a default the composition root + consumes (`app.py` calling `default_scheduler_adapter()`), not a branch + inside `AutomationService`. + +## Correctness notes + +Two platform-specific correctness details, each covered by a regression test. + +- **Honor `XDG_CONFIG_HOME` for user units** — `manifest_path_for` hard-coded + `~/.config/systemd/user`. When a Linux user sets `XDG_CONFIG_HOME`, systemd + searches `$XDG_CONFIG_HOME/systemd/user`, so units were written outside the + manager's search path and `enable` failed. `systemd_user_dir` now derives the + directory from an absolute `XDG_CONFIG_HOME` when present, falling back to + `~/.config/systemd/user` (a relative value is invalid per the XDG spec and is + ignored). +- **Gate `is_loaded` on `ActiveState`, not just `LoadState`** — a stopped, + disabled, or failed timer still reports `LoadState=loaded` because systemd can + parse its unit file, so `automation status` printed `scheduler loaded: yes` + for a timer that schedules nothing. `is_loaded` now also requires + `ActiveState` to be `active`/`activating`. diff --git a/src/codealmanac/app.py b/src/codealmanac/app.py index f9368800..8e91c0da 100644 --- a/src/codealmanac/app.py +++ b/src/codealmanac/app.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from codealmanac import __version__ -from codealmanac.integrations.automation import LaunchdSchedulerAdapter +from codealmanac.integrations.automation import default_scheduler_adapter from codealmanac.integrations.harnesses import default_harness_adapters from codealmanac.integrations.runs import ( PsutilRunProcessController, @@ -196,7 +196,7 @@ def create_services( adapters: AppAdapters, ) -> Services: repositories = RepositoriesService(RepositoryStore(local_state.database_path)) - automation = AutomationService(adapters.scheduler or LaunchdSchedulerAdapter()) + automation = AutomationService(adapters.scheduler or default_scheduler_adapter()) config_service = ConfigService( ConfigStore(), local_state.config_path, diff --git a/src/codealmanac/cli/render/automation.py b/src/codealmanac/cli/render/automation.py index a8ecc091..65584a27 100644 --- a/src/codealmanac/cli/render/automation.py +++ b/src/codealmanac/cli/render/automation.py @@ -25,8 +25,8 @@ def render_automation_job_status(status: ScheduledJobStatus) -> None: print(f"{label}: not installed") return print(f"{label}: installed") - print(f" plist: {status.plist_path}") - print(f" launchd loaded: {'yes' if status.loaded else 'no'}") + print(f" manifest: {status.manifest_path}") + print(f" scheduler loaded: {'yes' if status.loaded else 'no'}") if status.interval is not None: print(f" interval: {duration_label(status.interval)}") if status.state is not None: diff --git a/src/codealmanac/integrations/automation/__init__.py b/src/codealmanac/integrations/automation/__init__.py index 7f9a0284..0f139ce4 100644 --- a/src/codealmanac/integrations/automation/__init__.py +++ b/src/codealmanac/integrations/automation/__init__.py @@ -1,3 +1,11 @@ -from codealmanac.integrations.automation.scheduler import LaunchdSchedulerAdapter +from codealmanac.integrations.automation.scheduler import ( + LaunchdSchedulerAdapter, + SystemdSchedulerAdapter, + default_scheduler_adapter, +) -__all__ = ["LaunchdSchedulerAdapter"] +__all__ = [ + "LaunchdSchedulerAdapter", + "SystemdSchedulerAdapter", + "default_scheduler_adapter", +] diff --git a/src/codealmanac/integrations/automation/scheduler/__init__.py b/src/codealmanac/integrations/automation/scheduler/__init__.py index bd8d556c..d56dfc58 100644 --- a/src/codealmanac/integrations/automation/scheduler/__init__.py +++ b/src/codealmanac/integrations/automation/scheduler/__init__.py @@ -1,5 +1,22 @@ +import sys + from codealmanac.integrations.automation.scheduler.launchd import ( LaunchdSchedulerAdapter, ) +from codealmanac.integrations.automation.scheduler.systemd import ( + SystemdSchedulerAdapter, +) +from codealmanac.services.automation.ports import SchedulerAdapter + + +def default_scheduler_adapter() -> SchedulerAdapter: + if sys.platform.startswith("linux"): + return SystemdSchedulerAdapter() + return LaunchdSchedulerAdapter() + -__all__ = ["LaunchdSchedulerAdapter"] +__all__ = [ + "LaunchdSchedulerAdapter", + "SystemdSchedulerAdapter", + "default_scheduler_adapter", +] diff --git a/src/codealmanac/integrations/automation/scheduler/launchd.py b/src/codealmanac/integrations/automation/scheduler/launchd.py index 979ddee0..5ede278f 100644 --- a/src/codealmanac/integrations/automation/scheduler/launchd.py +++ b/src/codealmanac/integrations/automation/scheduler/launchd.py @@ -6,6 +6,9 @@ from pathlib import Path from codealmanac.core.errors import ExecutionFailed +from codealmanac.integrations.automation.scheduler.process import ( + surface_process_error, +) from codealmanac.services.automation.models import ( EnvironmentVariable, ScheduledJob, @@ -25,36 +28,36 @@ class LaunchdInspection: class LaunchdSchedulerAdapter: def install(self, job: ScheduledJob) -> ScheduledJobStatus: - job.plist_path.parent.mkdir(parents=True, exist_ok=True) + job.manifest_path.parent.mkdir(parents=True, exist_ok=True) job.stdout_path.parent.mkdir(parents=True, exist_ok=True) job.stderr_path.parent.mkdir(parents=True, exist_ok=True) - with job.plist_path.open("wb") as handle: + with job.manifest_path.open("wb") as handle: plistlib.dump(launchd_plist(job), handle, sort_keys=False) self.bootout(job) self.bootstrap(job) return self.status(job) def uninstall(self, job: ScheduledJob) -> bool: - plist_existed = job.plist_path.exists() + plist_existed = job.manifest_path.exists() service_removed = self.bootout(job) - job.plist_path.unlink(missing_ok=True) + job.manifest_path.unlink(missing_ok=True) return plist_existed or service_removed def status(self, job: ScheduledJob) -> ScheduledJobStatus: inspection = self.inspect(job) - if not job.plist_path.exists(): + if not job.manifest_path.exists(): return ScheduledJobStatus( task=job.task, label=job.label, - plist_path=job.plist_path, + manifest_path=job.manifest_path, installed=False, loaded=inspection.loaded, ) - data = read_plist(job.plist_path) + data = read_plist(job.manifest_path) return ScheduledJobStatus( task=job.task, label=job.label, - plist_path=job.plist_path, + manifest_path=job.manifest_path, installed=True, loaded=inspection.loaded, interval=read_interval(data), @@ -66,7 +69,7 @@ def status(self, job: ScheduledJob) -> ScheduledJobStatus: def bootstrap(self, job: ScheduledJob) -> None: result = self.run_launchctl( - ("bootstrap", launchd_target(), str(job.plist_path)) + ("bootstrap", launchd_target(), str(job.manifest_path)) ) if result.returncode != 0: raise ExecutionFailed( @@ -194,13 +197,6 @@ def parse_integer(value: str) -> int | None: return None -def surface_process_error(result: subprocess.CompletedProcess[str]) -> str: - text = result.stderr.strip() or result.stdout.strip() - if len(text) > 500: - return f"{text[:500]}..." - return text or f"exit {result.returncode}" - - def service_not_found(result: subprocess.CompletedProcess[str]) -> bool: message = f"{result.stderr}\n{result.stdout}".casefold() return any( diff --git a/src/codealmanac/integrations/automation/scheduler/process.py b/src/codealmanac/integrations/automation/scheduler/process.py new file mode 100644 index 00000000..7f2778a9 --- /dev/null +++ b/src/codealmanac/integrations/automation/scheduler/process.py @@ -0,0 +1,8 @@ +import subprocess + + +def surface_process_error(result: subprocess.CompletedProcess[str]) -> str: + text = result.stderr.strip() or result.stdout.strip() + if len(text) > 500: + return f"{text[:500]}..." + return text or f"exit {result.returncode}" diff --git a/src/codealmanac/integrations/automation/scheduler/systemd.py b/src/codealmanac/integrations/automation/scheduler/systemd.py new file mode 100644 index 00000000..4ccdc005 --- /dev/null +++ b/src/codealmanac/integrations/automation/scheduler/systemd.py @@ -0,0 +1,292 @@ +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +from pathlib import Path + +from codealmanac.core.errors import ExecutionFailed +from codealmanac.integrations.automation.scheduler.process import ( + surface_process_error, +) +from codealmanac.services.automation.models import ( + EnvironmentVariable, + ScheduledJob, + ScheduledJobState, + ScheduledJobStatus, +) + +SystemctlRunner = Callable[[tuple[str, ...]], "subprocess.CompletedProcess[str]"] + + +@dataclass(frozen=True) +class SystemdServiceInspection: + state: ScheduledJobState | None = None + last_exit_code: int | None = None + pid: int | None = None + + +class SystemdSchedulerAdapter: + def __init__(self, run_command: SystemctlRunner | None = None): + self.run_command = run_command or run_systemctl + + def install(self, job: ScheduledJob) -> ScheduledJobStatus: + job.manifest_path.parent.mkdir(parents=True, exist_ok=True) + job.stdout_path.parent.mkdir(parents=True, exist_ok=True) + job.stderr_path.parent.mkdir(parents=True, exist_ok=True) + service_unit_path(job).write_text(service_unit(job), encoding="utf-8") + job.manifest_path.write_text(timer_unit(job), encoding="utf-8") + self.daemon_reload(job) + self.enable(job) + self.restart(job) + return self.status(job) + + def uninstall(self, job: ScheduledJob) -> bool: + units_existed = job.manifest_path.exists() or service_unit_path(job).exists() + timer_disabled = self.disable(job) + self.stop_service(job) + job.manifest_path.unlink(missing_ok=True) + service_unit_path(job).unlink(missing_ok=True) + self.daemon_reload(job) + self.reset_failed(job) + return units_existed or timer_disabled + + def status(self, job: ScheduledJob) -> ScheduledJobStatus: + if not job.manifest_path.exists(): + return ScheduledJobStatus( + task=job.task, + label=job.label, + manifest_path=job.manifest_path, + installed=False, + loaded=self.is_loaded(job), + ) + inspection = self.inspect_service(job) + return ScheduledJobStatus( + task=job.task, + label=job.label, + manifest_path=job.manifest_path, + installed=True, + loaded=self.is_loaded(job), + interval=read_timer_interval(job.manifest_path.read_text(encoding="utf-8")), + state=inspection.state, + last_exit_code=inspection.last_exit_code, + pid=inspection.pid, + ) + + def enable(self, job: ScheduledJob) -> None: + result = self.run_command(("enable", timer_name(job))) + if result.returncode != 0: + raise ExecutionFailed( + "systemctl enable failed for " + f"{job.label}: {surface_process_error(result)}" + ) + + def restart(self, job: ScheduledJob) -> None: + result = self.run_command(("restart", timer_name(job))) + if result.returncode != 0: + raise ExecutionFailed( + "systemctl restart failed for " + f"{job.label}: {surface_process_error(result)}" + ) + + def disable(self, job: ScheduledJob) -> bool: + result = self.run_command(("disable", "--now", timer_name(job))) + if result.returncode == 0: + return True + if unit_not_found(result): + return False + raise ExecutionFailed( + f"systemctl disable failed for {job.label}: {surface_process_error(result)}" + ) + + def stop_service(self, job: ScheduledJob) -> None: + result = self.run_command(("stop", service_name(job))) + if result.returncode != 0 and not unit_not_found(result): + raise ExecutionFailed( + "systemctl stop failed for " + f"{job.label}: {surface_process_error(result)}" + ) + + def reset_failed(self, job: ScheduledJob) -> None: + # Stopping the timer can fire one last trigger that the service stop + # then kills, leaving a residual failed unit in the user manager. + result = self.run_command(("reset-failed", timer_name(job), service_name(job))) + if result.returncode != 0 and not unit_not_found(result): + raise ExecutionFailed( + "systemctl reset-failed failed for " + f"{job.label}: {surface_process_error(result)}" + ) + + def daemon_reload(self, job: ScheduledJob) -> None: + result = self.run_command(("daemon-reload",)) + if result.returncode != 0: + raise ExecutionFailed( + "systemctl daemon-reload failed for " + f"{job.label}: {surface_process_error(result)}" + ) + + def is_loaded(self, job: ScheduledJob) -> bool: + # A stopped, disabled, or failed timer still reports LoadState=loaded + # because systemd can parse its unit file. Only an active timer is + # actually scheduling runs, so gate on ActiveState too. + properties = self.show(timer_name(job), ("LoadState", "ActiveState")) + return properties.get("LoadState") == "loaded" and properties.get( + "ActiveState" + ) in ("active", "activating") + + def inspect_service(self, job: ScheduledJob) -> SystemdServiceInspection: + properties = self.show( + service_name(job), + ( + "ActiveState", + "ExecMainStatus", + "ExecMainExitTimestampMonotonic", + "MainPID", + ), + ) + if not properties: + return SystemdServiceInspection() + return SystemdServiceInspection( + state=parse_systemd_state(properties.get("ActiveState", "")), + last_exit_code=read_last_exit_code(properties), + pid=positive_integer(properties.get("MainPID", "")), + ) + + def show(self, unit: str, properties: tuple[str, ...]) -> dict[str, str]: + result = self.run_command(("show", unit, f"--property={','.join(properties)}")) + if result.returncode != 0: + return {} + return parse_show_properties(result.stdout) + + +def run_systemctl(args: tuple[str, ...]) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ("systemctl", "--user", *args), + check=False, + capture_output=True, + text=True, + ) + except OSError as error: + return subprocess.CompletedProcess( + args=("systemctl", "--user", *args), + returncode=1, + stdout="", + stderr=str(error), + ) + + +def service_unit_path(job: ScheduledJob) -> Path: + return job.manifest_path.with_suffix(".service") + + +def timer_name(job: ScheduledJob) -> str: + return f"{job.label}.timer" + + +def service_name(job: ScheduledJob) -> str: + return f"{job.label}.service" + + +def service_unit(job: ScheduledJob) -> str: + lines = [ + "[Unit]", + f"Description=CodeAlmanac {job.task.value} automation", + "", + "[Service]", + "Type=oneshot", + f"ExecStart={exec_start(job.program_arguments)}", + *environment_lines(job.environment), + f"StandardOutput=append:{job.stdout_path}", + f"StandardError=append:{job.stderr_path}", + ] + return "\n".join(lines) + "\n" + + +def timer_unit(job: ScheduledJob) -> str: + lines = [ + "[Unit]", + f"Description=CodeAlmanac {job.task.value} automation timer", + "", + "[Timer]", + "OnActiveSec=0", + f"OnUnitActiveSec={int(job.interval.total_seconds())}", + f"Unit={service_name(job)}", + "", + "[Install]", + "WantedBy=timers.target", + ] + return "\n".join(lines) + "\n" + + +def exec_start(arguments: tuple[str, ...]) -> str: + return " ".join(quote_unit_value(argument) for argument in arguments) + + +def environment_lines(values: tuple[EnvironmentVariable, ...]) -> list[str]: + return [ + f"Environment={quote_unit_value(f'{item.name}={item.value}')}" + for item in values + ] + + +def quote_unit_value(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + return f'"{escaped}"' + + +def read_timer_interval(text: str) -> timedelta | None: + for line in text.splitlines(): + key, separator, value = line.partition("=") + if separator and key.strip() == "OnUnitActiveSec": + seconds = positive_integer(value.strip()) + if seconds is not None: + return timedelta(seconds=seconds) + return None + + +def parse_show_properties(output: str) -> dict[str, str]: + properties: dict[str, str] = {} + for line in output.splitlines(): + key, separator, value = line.partition("=") + if separator: + properties[key.strip()] = value.strip() + return properties + + +def parse_systemd_state(value: str) -> ScheduledJobState: + if value in ("activating", "active", "deactivating"): + return ScheduledJobState.RUNNING + if value in ("inactive", "failed"): + return ScheduledJobState.IDLE + return ScheduledJobState.UNKNOWN + + +def read_last_exit_code(properties: dict[str, str]) -> int | None: + if properties.get("ExecMainExitTimestampMonotonic", "0") == "0": + return None + try: + return int(properties.get("ExecMainStatus", "")) + except ValueError: + return None + + +def positive_integer(value: str) -> int | None: + try: + parsed = int(value) + except ValueError: + return None + if parsed <= 0: + return None + return parsed + + +def unit_not_found(result: subprocess.CompletedProcess[str]) -> bool: + message = f"{result.stderr}\n{result.stdout}".casefold() + return any( + marker in message + for marker in ( + "does not exist", + "not loaded", + "not found", + ) + ) diff --git a/src/codealmanac/services/automation/defaults.py b/src/codealmanac/services/automation/defaults.py index 4920587f..53d3e5a2 100644 --- a/src/codealmanac/services/automation/defaults.py +++ b/src/codealmanac/services/automation/defaults.py @@ -8,7 +8,7 @@ GARDEN_LABEL = "com.codealmanac.garden" UPDATE_LABEL = "com.codealmanac.update" -LAUNCHD_FALLBACK_PATHS = ( +MACOS_FALLBACK_PATHS = ( "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", @@ -17,6 +17,14 @@ "/sbin", ) +LINUX_FALLBACK_PATHS = ( + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", +) + def duration_text(value: timedelta) -> str: seconds = int(value.total_seconds()) diff --git a/src/codealmanac/services/automation/jobs.py b/src/codealmanac/services/automation/jobs.py index a13a73c8..99f91125 100644 --- a/src/codealmanac/services/automation/jobs.py +++ b/src/codealmanac/services/automation/jobs.py @@ -10,7 +10,8 @@ DEFAULT_GARDEN_INTERVAL, DEFAULT_SYNC_INTERVAL, DEFAULT_UPDATE_INTERVAL, - LAUNCHD_FALLBACK_PATHS, + LINUX_FALLBACK_PATHS, + MACOS_FALLBACK_PATHS, ) from codealmanac.services.automation.definitions import task_definition from codealmanac.services.automation.models import ( @@ -36,7 +37,7 @@ def job_for_task( return ScheduledJob( task=task, label=definition.label, - plist_path=plist_path_for(task, resolved_home), + manifest_path=manifest_path_for(task, resolved_home), program_arguments=program_arguments_for(task, codealmanac_executable), interval=interval, environment=( @@ -111,22 +112,46 @@ def default_interval(task: AutomationTask) -> timedelta: return DEFAULT_UPDATE_INTERVAL -def plist_path_for(task: AutomationTask, home: Path) -> Path: +def manifest_path_for( + task: AutomationTask, + home: Path, + platform: str | None = None, + config_home: str | None = None, +) -> Path: definition = task_definition(task) + if is_linux(platform): + return systemd_user_dir(home, config_home) / f"{definition.label}.timer" return home / "Library/LaunchAgents" / f"{definition.label}.plist" -def launch_path(home: Path, env_path: str | None) -> str: +def systemd_user_dir(home: Path, config_home: str | None = None) -> Path: + raw = config_home if config_home is not None else os.environ.get("XDG_CONFIG_HOME") + if raw and os.path.isabs(raw): + return Path(raw) / "systemd" / "user" + return home / ".config" / "systemd" / "user" + + +def launch_path(home: Path, env_path: str | None, platform: str | None = None) -> str: values = [ item.strip() for item in (env_path or os.environ.get("PATH", "")).split(":") if item.strip() ] values.extend([str(home / ".local/bin"), str(home / ".bun/bin")]) - values.extend(LAUNCHD_FALLBACK_PATHS) + values.extend(fallback_paths(platform)) return ":".join(unique(values)) +def fallback_paths(platform: str | None) -> tuple[str, ...]: + if is_linux(platform): + return LINUX_FALLBACK_PATHS + return MACOS_FALLBACK_PATHS + + +def is_linux(platform: str | None) -> bool: + return (platform or sys.platform).startswith("linux") + + def unique(values: Sequence[str]) -> tuple[str, ...]: seen: list[str] = [] for value in values: diff --git a/src/codealmanac/services/automation/models.py b/src/codealmanac/services/automation/models.py index 64d7bd25..f3416f58 100644 --- a/src/codealmanac/services/automation/models.py +++ b/src/codealmanac/services/automation/models.py @@ -33,7 +33,7 @@ def require_name(cls, value: str) -> str: class ScheduledJob(CodeAlmanacModel): task: AutomationTask label: str - plist_path: Path + manifest_path: Path program_arguments: tuple[str, ...] interval: timedelta environment: tuple[EnvironmentVariable, ...] @@ -63,7 +63,7 @@ def positive_interval(cls, value: timedelta) -> timedelta: class ScheduledJobStatus(CodeAlmanacModel): task: AutomationTask label: str - plist_path: Path + manifest_path: Path installed: bool loaded: bool interval: timedelta | None = None @@ -77,7 +77,7 @@ class AutomationTaskApplyResult(CodeAlmanacModel): task: AutomationTask enabled: bool interval: timedelta - plist_path: Path + manifest_path: Path changed: bool diff --git a/src/codealmanac/services/automation/ports.py b/src/codealmanac/services/automation/ports.py index aadf9bcc..1f77bf5a 100644 --- a/src/codealmanac/services/automation/ports.py +++ b/src/codealmanac/services/automation/ports.py @@ -8,7 +8,7 @@ def install(self, job: ScheduledJob) -> ScheduledJobStatus: """Install and activate one scheduled job.""" def uninstall(self, job: ScheduledJob) -> bool: - """Remove one scheduled job. Return true when a plist was removed.""" + """Remove one scheduled job. Return true when a manifest was removed.""" def status(self, job: ScheduledJob) -> ScheduledJobStatus: """Read persisted scheduler state for one job.""" diff --git a/src/codealmanac/services/automation/service.py b/src/codealmanac/services/automation/service.py index 634ec466..abae06e3 100644 --- a/src/codealmanac/services/automation/service.py +++ b/src/codealmanac/services/automation/service.py @@ -45,7 +45,7 @@ def reconcile_task( task=request.task, enabled=request.enabled, interval=request.every, - plist_path=job.plist_path, + manifest_path=job.manifest_path, changed=changed, ) @@ -64,7 +64,7 @@ def remove_all( codealmanac_executable=request.codealmanac_executable, ) if self.scheduler.uninstall(job): - removed.append(job.plist_path) + removed.append(job.manifest_path) return AutomationRemoveResult(tasks=tasks, removed=tuple(removed)) def status(self, request: AutomationStatusRequest) -> AutomationStatusReport: diff --git a/tests/test_architecture.py b/tests/test_architecture.py index f09a32cc..d2117bfa 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -1401,13 +1401,14 @@ def test_automation_service_keeps_selection_and_job_construction_boundaries(): "AutomationTaskDefinition", "DEFAULT_SYNC_INTERVAL", "DEFAULT_GARDEN_INTERVAL", - "LAUNCHD_FALLBACK_PATHS", + "MACOS_FALLBACK_PATHS", + "LINUX_FALLBACK_PATHS", "AUTOMATION_SYNC_CLAIM_OWNER", "duration_text(", "ValidationFailed", "EnvironmentVariable(", "program_arguments_for(", - "plist_path_for(", + "manifest_path_for(", "launch_path(", "interval_for(", "selected_tasks(", diff --git a/tests/test_automation_service.py b/tests/test_automation_service.py index 37f86f99..11eb18a2 100644 --- a/tests/test_automation_service.py +++ b/tests/test_automation_service.py @@ -9,12 +9,21 @@ from codealmanac.app import create_app from codealmanac.cli.render.automation import render_automation_job_status from codealmanac.core.errors import ExecutionFailed +from codealmanac.integrations.automation.scheduler import default_scheduler_adapter from codealmanac.integrations.automation.scheduler.launchd import ( LaunchdSchedulerAdapter, parse_launchd_inspection, ) +from codealmanac.integrations.automation.scheduler.systemd import ( + SystemdSchedulerAdapter, + exec_start, + service_unit_path, + timer_unit, +) +from codealmanac.services.automation.jobs import launch_path, manifest_path_for from codealmanac.services.automation.models import ( AutomationTask, + EnvironmentVariable, ScheduledJob, ScheduledJobState, ScheduledJobStatus, @@ -35,17 +44,17 @@ def __init__(self): def install(self, job: ScheduledJob) -> ScheduledJobStatus: self.installed.append(job) - self.loaded.add(job.plist_path) + self.loaded.add(job.manifest_path) return status_for(job, installed=True) def uninstall(self, job: ScheduledJob) -> bool: self.uninstalled.append(job) - existed = job.plist_path in self.loaded - self.loaded.discard(job.plist_path) + existed = job.manifest_path in self.loaded + self.loaded.discard(job.manifest_path) return existed def status(self, job: ScheduledJob) -> ScheduledJobStatus: - return status_for(job, installed=job.plist_path in self.loaded) + return status_for(job, installed=job.manifest_path in self.loaded) def test_automation_reconcile_enabled_installs_explicit_task( @@ -80,9 +89,7 @@ def test_automation_reconcile_disabled_removes_explicit_task( ) -> None: scheduler = FakeSchedulerAdapter() app = automation_app(isolated_home, scheduler) - scheduler.loaded.add( - isolated_home / "Library/LaunchAgents/com.codealmanac.garden.plist" - ) + scheduler.loaded.add(manifest_path_for(AutomationTask.GARDEN, isolated_home)) result = app.automation.reconcile_task( ReconcileAutomationTaskRequest( @@ -114,9 +121,7 @@ def test_automation_remove_all_is_explicit( scheduler = FakeSchedulerAdapter() app = automation_app(isolated_home, scheduler) for task in AutomationTask: - scheduler.loaded.add( - isolated_home / f"Library/LaunchAgents/com.codealmanac.{task.value}.plist" - ) + scheduler.loaded.add(manifest_path_for(task, isolated_home)) result = app.automation.remove_all(RemoveAllAutomationRequest(home=isolated_home)) @@ -160,7 +165,7 @@ def fake_run( job = ScheduledJob( task=AutomationTask.SYNC, label="com.codealmanac.sync", - plist_path=tmp_path / "com.codealmanac.sync.plist", + manifest_path=tmp_path / "com.codealmanac.sync.plist", program_arguments=("/usr/local/bin/codealmanac", "sync"), interval=timedelta(minutes=5), environment=(), @@ -170,7 +175,7 @@ def fake_run( status = LaunchdSchedulerAdapter().install(job) - data = plistlib.loads(job.plist_path.read_bytes()) + data = plistlib.loads(job.manifest_path.read_bytes()) assert data["Label"] == "com.codealmanac.sync" assert data["Program"] == "/usr/local/bin/codealmanac" assert data["ProgramArguments"][-1] == "sync" @@ -205,7 +210,7 @@ def fake_run( job = ScheduledJob( task=AutomationTask.SYNC, label="com.codealmanac.sync", - plist_path=tmp_path / "missing.plist", + manifest_path=tmp_path / "missing.plist", program_arguments=("/usr/local/bin/codealmanac", "sync"), interval=timedelta(minutes=5), environment=(), @@ -246,7 +251,7 @@ def fake_run( job = ScheduledJob( task=AutomationTask.SYNC, label="com.codealmanac.sync", - plist_path=plist_path, + manifest_path=plist_path, program_arguments=("/usr/local/bin/codealmanac", "sync"), interval=timedelta(minutes=5), environment=(), @@ -283,7 +288,7 @@ def fake_run( job = ScheduledJob( task=AutomationTask.SYNC, label="com.codealmanac.sync", - plist_path=tmp_path / "missing.plist", + manifest_path=tmp_path / "missing.plist", program_arguments=("/usr/local/bin/codealmanac", "sync"), interval=timedelta(minutes=5), environment=(), @@ -344,7 +349,7 @@ def test_automation_status_renders_run_health( status = ScheduledJobStatus( task=AutomationTask.SYNC, label="com.codealmanac.sync", - plist_path=tmp_path / "com.codealmanac.sync.plist", + manifest_path=tmp_path / "com.codealmanac.sync.plist", installed=True, loaded=True, interval=timedelta(hours=5), @@ -371,7 +376,7 @@ def status_for(job: ScheduledJob, installed: bool) -> ScheduledJobStatus: return ScheduledJobStatus( task=job.task, label=job.label, - plist_path=job.plist_path, + manifest_path=job.manifest_path, installed=installed, loaded=installed, interval=job.interval if installed else None, @@ -381,6 +386,7 @@ def status_for(job: ScheduledJob, installed: bool) -> ScheduledJobStatus: def completed_process( args: tuple[str, ...], returncode: int = 0, + stdout: str = "", stderr: str = "", ): from subprocess import CompletedProcess @@ -388,6 +394,268 @@ def completed_process( return CompletedProcess( args=args, returncode=returncode, - stdout="", + stdout=stdout, stderr=stderr, ) + + +class FakeSystemctl: + def __init__( + self, + show_outputs: dict[str, str] | None = None, + failures: dict[str, tuple[int, str]] | None = None, + ): + self.calls: list[tuple[str, ...]] = [] + self.show_outputs = show_outputs or {} + self.failures = failures or {} + + def __call__(self, args: tuple[str, ...]): + self.calls.append(args) + if args[0] in self.failures: + returncode, stderr = self.failures[args[0]] + return completed_process(args, returncode=returncode, stderr=stderr) + if args[0] == "show": + return completed_process(args, stdout=self.show_outputs.get(args[1], "")) + return completed_process(args) + + +def systemd_job(tmp_path: Path) -> ScheduledJob: + return ScheduledJob( + task=AutomationTask.SYNC, + label="com.codealmanac.sync", + manifest_path=tmp_path / "systemd/user/com.codealmanac.sync.timer", + program_arguments=("/usr/local/bin/codealmanac", "sync"), + interval=timedelta(minutes=5), + environment=(EnvironmentVariable(name="PATH", value="/custom/bin"),), + stdout_path=tmp_path / "logs/sync.out.log", + stderr_path=tmp_path / "logs/sync.err.log", + ) + + +def test_manifest_path_for_selects_platform_manifest(tmp_path: Path) -> None: + assert manifest_path_for(AutomationTask.SYNC, tmp_path, platform="darwin") == ( + tmp_path / "Library/LaunchAgents/com.codealmanac.sync.plist" + ) + assert manifest_path_for(AutomationTask.SYNC, tmp_path, platform="linux") == ( + tmp_path / ".config/systemd/user/com.codealmanac.sync.timer" + ) + + +def test_manifest_path_for_honors_xdg_config_home(tmp_path: Path) -> None: + xdg = tmp_path / "xdg" + assert manifest_path_for( + AutomationTask.SYNC, tmp_path, platform="linux", config_home=str(xdg) + ) == (xdg / "systemd/user/com.codealmanac.sync.timer") + # A relative XDG_CONFIG_HOME is invalid per the spec and is ignored. + assert manifest_path_for( + AutomationTask.SYNC, tmp_path, platform="linux", config_home="relative/path" + ) == (tmp_path / ".config/systemd/user/com.codealmanac.sync.timer") + + +def test_launch_path_selects_platform_fallbacks(tmp_path: Path) -> None: + darwin = launch_path(tmp_path, "/custom/bin", platform="darwin").split(":") + linux = launch_path(tmp_path, "/custom/bin", platform="linux").split(":") + + assert darwin == [ + "/custom/bin", + str(tmp_path / ".local/bin"), + str(tmp_path / ".bun/bin"), + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + assert linux == [ + "/custom/bin", + str(tmp_path / ".local/bin"), + str(tmp_path / ".bun/bin"), + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + + +def test_default_scheduler_adapter_selects_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("sys.platform", "linux") + assert isinstance(default_scheduler_adapter(), SystemdSchedulerAdapter) + monkeypatch.setattr("sys.platform", "darwin") + assert isinstance(default_scheduler_adapter(), LaunchdSchedulerAdapter) + + +def test_systemd_adapter_installs_timer_and_service_units(tmp_path: Path) -> None: + systemctl = FakeSystemctl( + show_outputs={ + "com.codealmanac.sync.timer": "LoadState=loaded\nActiveState=active\n", + "com.codealmanac.sync.service": ( + "ActiveState=inactive\n" + "ExecMainStatus=0\n" + "ExecMainExitTimestampMonotonic=0\n" + "MainPID=0\n" + ), + } + ) + job = systemd_job(tmp_path) + + status = SystemdSchedulerAdapter(run_command=systemctl).install(job) + + service_text = service_unit_path(job).read_text(encoding="utf-8") + timer_text = job.manifest_path.read_text(encoding="utf-8") + assert "Type=oneshot" in service_text + assert 'ExecStart="/usr/local/bin/codealmanac" "sync"' in service_text + assert 'Environment="PATH=/custom/bin"' in service_text + assert f"StandardOutput=append:{job.stdout_path}" in service_text + assert f"StandardError=append:{job.stderr_path}" in service_text + assert "OnActiveSec=0" in timer_text + assert "OnUnitActiveSec=300" in timer_text + assert "Unit=com.codealmanac.sync.service" in timer_text + assert "WantedBy=timers.target" in timer_text + assert [call[0] for call in systemctl.calls[:3]] == [ + "daemon-reload", + "enable", + "restart", + ] + assert systemctl.calls[1] == ("enable", "com.codealmanac.sync.timer") + assert status.installed is True + assert status.loaded is True + assert status.interval == timedelta(minutes=5) + assert status.state == ScheduledJobState.IDLE + assert status.last_exit_code is None + assert status.pid is None + + +def test_systemd_exec_start_quotes_and_escapes() -> None: + assert exec_start(("/opt/my tools/codealmanac", "sync", "100%")) == ( + '"/opt/my tools/codealmanac" "sync" "100%%"' + ) + + +def test_systemd_uninstall_removes_units_and_reloads(tmp_path: Path) -> None: + systemctl = FakeSystemctl() + job = systemd_job(tmp_path) + job.manifest_path.parent.mkdir(parents=True, exist_ok=True) + job.manifest_path.write_text("timer", encoding="utf-8") + service_unit_path(job).write_text("service", encoding="utf-8") + + removed = SystemdSchedulerAdapter(run_command=systemctl).uninstall(job) + + assert removed is True + assert not job.manifest_path.exists() + assert not service_unit_path(job).exists() + assert systemctl.calls == [ + ("disable", "--now", "com.codealmanac.sync.timer"), + ("stop", "com.codealmanac.sync.service"), + ("daemon-reload",), + ("reset-failed", "com.codealmanac.sync.timer", "com.codealmanac.sync.service"), + ] + + +def test_systemd_uninstall_tolerates_unit_not_found(tmp_path: Path) -> None: + systemctl = FakeSystemctl( + failures={ + "disable": (1, "Unit file com.codealmanac.sync.timer does not exist."), + "stop": (1, "Unit com.codealmanac.sync.service not loaded."), + } + ) + job = systemd_job(tmp_path) + + assert SystemdSchedulerAdapter(run_command=systemctl).uninstall(job) is False + + +def test_systemd_uninstall_preserves_units_on_real_failure(tmp_path: Path) -> None: + systemctl = FakeSystemctl(failures={"disable": (1, "Access denied")}) + job = systemd_job(tmp_path) + job.manifest_path.parent.mkdir(parents=True, exist_ok=True) + job.manifest_path.write_text("timer", encoding="utf-8") + + with pytest.raises(ExecutionFailed, match="Access denied"): + SystemdSchedulerAdapter(run_command=systemctl).uninstall(job) + + assert job.manifest_path.exists() + + +def test_systemd_status_reports_running_service(tmp_path: Path) -> None: + systemctl = FakeSystemctl( + show_outputs={ + "com.codealmanac.sync.timer": "LoadState=loaded\n", + "com.codealmanac.sync.service": ( + "ActiveState=activating\n" + "ExecMainStatus=0\n" + "ExecMainExitTimestampMonotonic=0\n" + "MainPID=4321\n" + ), + } + ) + job = systemd_job(tmp_path) + job.manifest_path.parent.mkdir(parents=True, exist_ok=True) + job.manifest_path.write_text(timer_unit(job), encoding="utf-8") + + status = SystemdSchedulerAdapter(run_command=systemctl).status(job) + + assert status.state == ScheduledJobState.RUNNING + assert status.pid == 4321 + assert status.last_exit_code is None + assert status.interval == timedelta(minutes=5) + + +def test_systemd_status_reports_last_failed_run(tmp_path: Path) -> None: + systemctl = FakeSystemctl( + show_outputs={ + "com.codealmanac.sync.timer": "LoadState=loaded\n", + "com.codealmanac.sync.service": ( + "ActiveState=failed\n" + "ExecMainStatus=2\n" + "ExecMainExitTimestampMonotonic=12345\n" + "MainPID=0\n" + ), + } + ) + job = systemd_job(tmp_path) + job.manifest_path.parent.mkdir(parents=True, exist_ok=True) + job.manifest_path.write_text(timer_unit(job), encoding="utf-8") + + status = SystemdSchedulerAdapter(run_command=systemctl).status(job) + + assert status.state == ScheduledJobState.IDLE + assert status.last_exit_code == 2 + assert status.pid is None + + +def test_systemd_status_handles_missing_manifest(tmp_path: Path) -> None: + systemctl = FakeSystemctl( + show_outputs={"com.codealmanac.sync.timer": "LoadState=not-found\n"} + ) + job = systemd_job(tmp_path) + + status = SystemdSchedulerAdapter(run_command=systemctl).status(job) + + assert status.installed is False + assert status.loaded is False + assert status.interval is None + + +def test_systemd_status_reports_inactive_timer_as_unloaded(tmp_path: Path) -> None: + systemctl = FakeSystemctl( + show_outputs={ + "com.codealmanac.sync.timer": "LoadState=loaded\nActiveState=inactive\n", + "com.codealmanac.sync.service": ( + "ActiveState=inactive\n" + "ExecMainStatus=0\n" + "ExecMainExitTimestampMonotonic=0\n" + "MainPID=0\n" + ), + } + ) + job = systemd_job(tmp_path) + job.manifest_path.parent.mkdir(parents=True, exist_ok=True) + job.manifest_path.write_text(timer_unit(job), encoding="utf-8") + + status = SystemdSchedulerAdapter(run_command=systemctl).status(job) + + assert status.installed is True + assert status.loaded is False diff --git a/tests/test_cli.py b/tests/test_cli.py index 72feae92..fb34f1fb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -209,7 +209,7 @@ def install(self, job: ScheduledJob) -> ScheduledJobStatus: return ScheduledJobStatus( task=job.task, label=job.label, - plist_path=job.plist_path, + manifest_path=job.manifest_path, installed=True, loaded=True, interval=job.interval, @@ -223,7 +223,7 @@ def status(self, job: ScheduledJob) -> ScheduledJobStatus: return ScheduledJobStatus( task=job.task, label=job.label, - plist_path=job.plist_path, + manifest_path=job.manifest_path, installed=False, loaded=False, ) diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 18c997a0..00ee9a15 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -322,7 +322,7 @@ def scheduler_status(job: ScheduledJob, installed: bool) -> ScheduledJobStatus: return ScheduledJobStatus( task=job.task, label=job.label, - plist_path=job.plist_path, + manifest_path=job.manifest_path, installed=installed, loaded=installed, interval=job.interval if installed else None, diff --git a/tests/test_setup_service.py b/tests/test_setup_service.py index d930d901..c78cde29 100644 --- a/tests/test_setup_service.py +++ b/tests/test_setup_service.py @@ -423,7 +423,7 @@ def reconcile_task( task=request.task, enabled=request.enabled, interval=request.every, - plist_path=self.home / f"{request.task.value}.plist", + manifest_path=self.home / f"{request.task.value}.plist", changed=True, )