Initial code commit
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / test (3.14) (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / precommit-and-security (push) Canceled after 11s
CI / typecheck (push) Canceled after 11s

This commit is contained in:
2026-09-25 11:16:19 +01:00
parent 427daa7f1a
commit aded37d491
64 changed files with 8201 additions and 24 deletions
+393
View File
@@ -0,0 +1,393 @@
from __future__ import annotations
import io
import os
import tempfile
from dataclasses import replace
import pytest
from schedls.backends.base import CommandPlan, FileChange, MutationResult, Plan
from schedls.backends.cron import CronBackend
from schedls.backends.systemd import SystemdBackend
from schedls.errors import InvalidScheduleError, OperationalError, SafetyRefusalError
from schedls.interact import Interaction
from schedls.models import (
Backend,
Command,
JobSource,
JobSpec,
Schedule,
ScheduledJob,
ScheduleKind,
Scope,
SystemdDetails,
)
from schedls.operations import doctor as doctor_ops
from schedls.operations import inspect as inspect_ops
from schedls.operations import mutate as mutate_ops
from schedls.output import Output
from ..conftest import FakeRunner
def _job(name: str = "backup") -> ScheduledJob:
return ScheduledJob(
name=name,
backend=Backend.SYSTEMD,
scope=Scope.USER,
managed=True,
enabled=True,
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"),
command=Command(argv=("/usr/local/bin/backup",)),
source=JobSource("systemd user timer"),
)
def _output() -> tuple[Output, io.StringIO]:
stream = io.StringIO()
return Output(color="never", stdout=stream), stream
def test_render_list_table() -> None:
output, stream = _output()
inspect_ops.render_list(output, [_job(), _job("cleanup")], [])
text = stream.getvalue()
assert "NAME" in text and "SCHEDULE" in text
assert "backup" in text and "cleanup" in text
def test_render_list_json() -> None:
output, stream = _output()
output.json_mode = True
inspect_ops.render_list(output, [_job()], ["note"])
assert '"schema_version": 1' in stream.getvalue()
def test_render_show() -> None:
output, stream = _output()
inspect_ops.render_show(output, _job())
text = stream.getvalue()
assert "Managed by schedls" in text
assert "systemd user timer" in text
def test_filter_jobs() -> None:
jobs = [_job(), _job("other")]
assert inspect_ops.filter_jobs(jobs, managed=True) == jobs
assert inspect_ops.filter_jobs(jobs, managed=False) == []
assert inspect_ops.filter_jobs(jobs, enabled=False) == []
assert len(inspect_ops.filter_jobs(jobs, backend=Backend.SYSTEMD)) == 2
def test_collect_reports_unavailable_backend() -> None:
backend = SystemdBackend(FakeRunner())
jobs, warnings = inspect_ops.collect([backend], [Scope.USER])
assert jobs == []
assert any("unavailable" in warning for warning in warnings)
class _DummyBackend:
name = "systemd"
def __init__(self, result: MutationResult) -> None:
self.result = result
self.applied = False
def apply(self, plan: Plan) -> MutationResult:
self.applied = True
return self.result
def test_run_plan_dry_run(tmp_path) -> None:
output, stream = _output()
plan = Plan(backend="systemd", action="create")
plan.files = [FileChange(str(tmp_path / "example.service"), "data")]
plan.commands = [CommandPlan(("systemctl", "reload"), "reload")]
backend = _DummyBackend(MutationResult(changed=True))
result = mutate_ops.run_plan(backend, plan, output, Interaction(output=output), dry_run=True)
assert result.dry_run is True
assert backend.applied is False
assert "No changes made." in stream.getvalue()
def test_run_plan_confirms_and_applies() -> None:
output, stream = _output()
plan = Plan(backend="systemd", action="create")
backend = _DummyBackend(MutationResult(changed=True, messages=["done"]))
interaction = Interaction(output=output, assume_yes=True)
result = mutate_ops.run_plan(backend, plan, output, interaction)
assert backend.applied is True
assert result.changed is True
assert "done" in stream.getvalue()
def test_run_plan_json_mode() -> None:
output, stream = _output()
output.json_mode = True
plan = Plan(backend="systemd", action="create")
backend = _DummyBackend(MutationResult(changed=True))
mutate_ops.run_plan(backend, plan, output, Interaction(output=output, assume_yes=True))
assert '"action": "create"' in stream.getvalue()
def _doctor_runner() -> FakeRunner:
def systemctl(argv, _input):
if "is-system-running" in argv:
return ("running\n", "")
return ("", "")
def crontab(argv, _input):
if "-V" in argv:
return ("cronie 1.7.2\n", "")
return ("", "")
return FakeRunner(
handlers={
"systemctl": systemctl,
"crontab": crontab,
"loginctl": lambda argv, _input: ("Linger=no\n", ""),
},
available={"systemctl", "systemd-analyze", "crontab", "loginctl"},
)
def test_doctor_human() -> None:
runner = _doctor_runner()
output, stream = _output()
doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner))
text = stream.getvalue()
assert "Cronie" in text
assert "usable" in text
def test_doctor_json() -> None:
runner = _doctor_runner()
output, stream = _output()
output.json_mode = True
doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner))
assert '"result": "usable"' in stream.getvalue()
def test_systemd_backend_discovery_parses_units() -> None:
timer = (
"# Managed by schedls\n"
"# Name: backup\n"
"[Unit]\nDescription=foo\n[Timer]\nOnCalendar=*-*-* 02:00:00\nPersistent=true\nUnit=schedls-backup.service\n"
)
service = '[Service]\nExecStart="/usr/local/bin/backup" "/srv/data"\n'
with tempfile.TemporaryDirectory() as tmp:
timer_path = os.path.join(tmp, "schedls-backup.timer")
service_path = os.path.join(tmp, "schedls-backup.service")
with open(timer_path, "w") as handle:
handle.write(timer)
with open(service_path, "w") as handle:
handle.write(service)
def systemctl(argv, _input):
if "is-system-running" in argv:
return ("running\n", "")
if "list-unit-files" in argv or "list-units" in argv:
return ("schedls-backup.timer enabled\n", "")
if "show" in argv:
unit = argv[argv.index("show") + 1]
if unit.endswith(".timer"):
return (
f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n"
"UnitFileState=enabled\nActiveState=active\nSubState=waiting\n"
"NextElapseUSecRealtime=1758700800000000\nTriggers=schedls-backup.service\n",
"",
)
return (
f"Id={unit}\nLoadState=loaded\nFragmentPath={service_path}\nResult=success\n",
"",
)
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
backend = SystemdBackend(runner)
jobs = backend.discover([Scope.USER])
assert len(jobs) == 1
job = jobs[0]
assert job.name == "backup"
assert job.managed is True
assert job.schedule.expression == "*-*-* 02:00:00"
assert job.command.argv == ("/usr/local/bin/backup", "/srv/data")
assert job.enabled is True
assert job.next_run is not None
def test_plan_update_refuses_unmanaged() -> None:
runner = FakeRunner()
backend = SystemdBackend(runner)
unmanaged = replace(_job("certbot"), managed=False)
spec = JobSpec(
name="certbot",
backend=Backend.SYSTEMD,
scope=Scope.USER,
command=Command(argv=("/bin/true",)),
calendar=("*-*-* 01:00:00",),
)
with pytest.raises(SafetyRefusalError):
backend.plan_update(unmanaged, spec)
def _managed_job(name: str = "backup", *, scope: Scope = Scope.USER, service_unit: str | None = None) -> ScheduledJob:
return replace(
_job(name),
scope=scope,
systemd=SystemdDetails(
timer_unit=f"schedls-{name}.timer",
service_unit=service_unit or f"schedls-{name}.service",
on_calendar=("*-*-* 02:00:00",),
),
)
def _spec(name: str = "backup") -> JobSpec:
return JobSpec(
name=name,
backend=Backend.SYSTEMD,
scope=Scope.USER,
command=Command(argv=("/bin/true",)),
calendar=("*-*-* 03:00:00",),
)
def _analyze_runner() -> FakeRunner:
def analyze(argv, _input):
if "calendar" in argv:
return ("Normalized form: *-*-* 03:00:00\n", "")
return ("", "")
return FakeRunner(handlers={"systemd-analyze": analyze}, available={"systemd-analyze"})
def test_plan_update_derives_unit_names(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
backend = SystemdBackend(_analyze_runner())
job = _managed_job(service_unit="../../../../tmp/evil.service")
plan = backend.plan_update(job, _spec())
expected_dir = os.path.join(str(tmp_path), "systemd", "user")
assert sorted(change.path for change in plan.files) == [
os.path.join(expected_dir, "schedls-backup.service"),
os.path.join(expected_dir, "schedls-backup.timer"),
]
assert "evil" not in " ".join(change.path for change in plan.files)
def test_verify_units_rejects_traversal() -> None:
backend = SystemdBackend(_analyze_runner())
with pytest.raises(SafetyRefusalError):
backend._verify_units({"../../evil.service": "content"})
def test_unit_dir_rejects_relative_xdg(monkeypatch) -> None:
monkeypatch.setenv("XDG_CONFIG_HOME", "relative")
backend = SystemdBackend(FakeRunner())
with pytest.raises(SafetyRefusalError):
backend._unit_dir(Scope.USER)
def test_plan_update_requires_scope(monkeypatch) -> None:
monkeypatch.setattr(os, "geteuid", lambda: 1000)
backend = SystemdBackend(_analyze_runner())
with pytest.raises(SafetyRefusalError):
backend.plan_update(_managed_job(scope=Scope.SYSTEM), _spec())
def test_validate_spec_rejects_unsafe_calendar() -> None:
backend = SystemdBackend(FakeRunner())
spec = replace(_spec(), calendar=("-x",))
with pytest.raises(InvalidScheduleError):
backend._validate_spec(spec)
def test_validate_spec_rejects_unsafe_jitter() -> None:
backend = SystemdBackend(_analyze_runner())
for jitter in ("5min\nExecStart=/bin/true", "-5min"):
with pytest.raises(InvalidScheduleError):
backend._validate_spec(replace(_spec(), jitter=jitter))
def test_apply_rolls_back_on_keyboard_interrupt(tmp_path) -> None:
target = tmp_path / "schedls-backup.service"
raised = []
def systemctl(argv, _input):
if not raised:
raised.append(True)
raise KeyboardInterrupt
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
backend = SystemdBackend(runner)
plan = Plan(backend="systemd", action="update")
plan.files = [FileChange(str(target), "content", mode=0o600)]
plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")]
plan.payload = {"scope": Scope.USER, "snapshots": {}}
with pytest.raises(KeyboardInterrupt):
backend.apply(plan)
assert not target.exists()
def test_rollback_disables_timer_on_create_failure(tmp_path) -> None:
target = tmp_path / "schedls-backup.timer"
def systemctl(argv, _input):
if "daemon-reload" in argv:
return (1, "", "boom")
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
backend = SystemdBackend(runner)
plan = Plan(backend="systemd", action="create")
plan.files = [FileChange(str(target), "content", mode=0o600)]
plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")]
plan.payload = {"scope": Scope.USER, "timer_unit": "schedls-backup.timer", "snapshots": {}}
with pytest.raises(OperationalError):
backend.apply(plan)
assert any("disable" in call[0] for call in runner.calls)
assert not target.exists()
def test_logs_rejects_unsafe_service_unit() -> None:
backend = SystemdBackend(FakeRunner(available={"journalctl"}))
job = _managed_job(service_unit="--output=json.service")
with pytest.raises(SafetyRefusalError):
backend.logs(job, lines=5, since=None)
def test_logs_returns_journal_text() -> None:
runner = FakeRunner(
handlers={"journalctl": lambda argv, _input: ("hello\n", "")},
available={"journalctl"},
)
backend = SystemdBackend(runner)
assert backend.logs(_managed_job(), lines=5, since="2 hours ago") == "hello\n"
argv = runner.calls[0][0]
assert "--since" in argv and "--lines=5" in argv
def test_prefix_named_unit_without_marker_is_unmanaged(tmp_path) -> None:
timer_path = tmp_path / "schedls-backup.timer"
timer_path.write_text("[Unit]\n[Timer]\nOnCalendar=*-*-* 02:00:00\n")
def systemctl(argv, _input):
if "is-system-running" in argv:
return ("running\n", "")
if "list-unit-files" in argv or "list-units" in argv:
return ("schedls-backup.timer enabled\n", "")
if "show" in argv:
unit = argv[argv.index("show") + 1]
if unit.endswith(".timer"):
return (f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n", "")
return ("", "")
return ("", "")
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
jobs = SystemdBackend(runner).discover([Scope.USER])
assert len(jobs) == 1
assert jobs[0].managed is False
assert jobs[0].name == "schedls-backup"