Files
schedls/tests/unit/test_cron_system.py
T

989 lines
36 KiB
Python

from __future__ import annotations
import os
from dataclasses import replace
import pytest
from schedls.backends.base import FileChange, Plan
from schedls.backends.cron import CronBackend, CronPaths, CrontabDocument
from schedls.errors import ConflictError, InvalidScheduleError, SafetyRefusalError
from schedls.models import Backend, Command, JobSpec, Scope
from schedls.operations import inspect as inspect_ops
from schedls.output import job_to_dict
from schedls.renderers import cron as renderer
def _make_paths(tmp_path) -> CronPaths:
root = tmp_path / "etc"
root.mkdir()
return CronPaths(
crontab=str(root / "crontab"),
cron_d=str(root / "cron.d"),
hourly=str(root / "cron.hourly"),
daily=str(root / "cron.daily"),
weekly=str(root / "cron.weekly"),
monthly=str(root / "cron.monthly"),
spool_crontabs=str(tmp_path / "var" / "spool" / "cron" / "crontabs"),
spool=str(tmp_path / "var" / "spool" / "cron"),
)
def _write(path, text: str, mode: int = 0o644):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
path.chmod(mode)
return path
def test_available_without_crontab_uses_file_sources(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
assert CronBackend(fake_runner, paths).available() is False
_write(tmp_path / "etc/cron.d/backup", "0 2 * * * root /usr/bin/backup\n")
assert CronBackend(fake_runner, paths).available() is True
def test_discovers_system_crontabs(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/crontab",
"SHELL=/bin/sh\n17 * * * * root /usr/bin/hourly\n@daily www-data /usr/bin/report\n",
)
_write(tmp_path / "etc/cron.d/backup", "0 2 * * * root /usr/bin/backup\n")
_write(
tmp_path / "etc/cron.d/schedls-cleanup",
renderer.render_system_file("cleanup", ["30 3 * * 0 deploy GREETING='hello world' /usr/bin/cleanup"]),
)
_write(tmp_path / "etc/cron.d/with.dot", "0 2 * * * root /usr/bin/ignored\n")
_write(tmp_path / "etc/cron.d/.placeholder", "")
jobs = CronBackend(fake_runner, paths).discover([Scope.SYSTEM])
by_name = {job.name: job for job in jobs}
assert set(by_name) == {"crontab:2", "crontab:3", "backup:1", "cleanup"}
assert by_name["crontab:2"].scope is Scope.SYSTEM
assert by_name["crontab:2"].cron.user == "root"
assert by_name["crontab:2"].command.raw == "/usr/bin/hourly"
assert by_name["crontab:3"].cron.user == "www-data"
assert by_name["backup:1"].managed is False
assert by_name["backup:1"].schedule.expression == "0 2 * * *"
assert by_name["backup:1"].source.path == paths.cron_d + "/backup"
managed = by_name["cleanup"]
assert managed.managed is True
assert managed.cron.user == "deploy"
assert managed.schedule.expression == "30 3 * * 0"
assert managed.command.raw == "/usr/bin/cleanup"
assert managed.cron.environment == (("GREETING", "hello world"),)
assert managed.source.path == paths.cron_d + "/schedls-cleanup"
def test_discovers_disabled_managed_user_cron_job(tmp_path, fake_runner) -> None:
text = (
"# schedls:begin name=backup\n"
"# schedls:disabled 0 2 * * * /usr/bin/backup /srv/data\n"
"# schedls:end name=backup\n"
)
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return text, ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
jobs = CronBackend(fake_runner, _make_paths(tmp_path)).discover([Scope.USER])
assert len(jobs) == 1
job = jobs[0]
assert job.managed is True
assert job.enabled is False
assert job.schedule.expression == "0 2 * * *"
assert job.command.raw == "/usr/bin/backup /srv/data"
assert inspect_ops.filter_jobs(jobs, enabled=False) == jobs
assert inspect_ops.job_status(job) == "disabled"
assert job_to_dict(job)["enabled"] is False
def test_discovers_managed_cron_environment_as_logical_values(tmp_path, fake_runner) -> None:
text = (
"# schedls:begin name=backup\n"
"0 2 * * * GREETING='hello world' RATE=100\\% /usr/bin/backup /srv/data\n"
"# schedls:end name=backup\n"
)
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return text, ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
job = CronBackend(fake_runner, _make_paths(tmp_path)).discover([Scope.USER])[0]
assert job.command.raw == "/usr/bin/backup /srv/data"
assert job.cron is not None
assert job.cron.environment == (("GREETING", "hello world"), ("RATE", "100%"))
assert job_to_dict(job)["cron"]["environment"] == [["GREETING", "hello world"], ["RATE", "100%"]]
def test_collect_warns_about_malformed_disabled_marker(tmp_path, fake_runner) -> None:
text = "# schedls:begin name=backup\n# schedls:disabled not a cron job\n# schedls:end name=backup\n"
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return text, ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
_, warnings = inspect_ops.collect([CronBackend(fake_runner, _make_paths(tmp_path))], [Scope.USER])
assert warnings == ["cron discovery warning: current user's crontab: line 2: malformed schedls disabled job"]
def test_collect_warns_and_mutation_refuses_noncanonical_environment_prefix(tmp_path, fake_runner) -> None:
text = '# schedls:begin name=backup\n0 2 * * * GREETING="hello world" /usr/bin/backup\n# schedls:end name=backup\n'
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return text, ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
jobs, warnings = inspect_ops.collect([backend], [Scope.USER])
assert warnings == ["cron discovery warning: current user's crontab: line 2: malformed schedls environment prefix"]
with pytest.raises(SafetyRefusalError):
backend.plan_update(
jobs[0],
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=jobs[0].command,
cron_expression="30 3 * * *",
),
)
def test_disabled_marker_outside_managed_block_is_not_discovered_as_a_job(tmp_path, fake_runner) -> None:
text = "# schedls:disabled 0 2 * * * /usr/bin/backup\n"
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return text, ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
jobs = CronBackend(fake_runner, _make_paths(tmp_path)).discover([Scope.USER])
assert jobs == []
def test_plan_set_enabled_user_cron_round_trips_job_line(tmp_path, fake_runner) -> None:
original = (
"# unrelated comment\n"
"# schedls:begin name=backup\n"
"0 2 * * * /usr/bin/backup /srv/data\n"
"# schedls:end name=backup\n"
)
state = {"text": original}
def crontab(argv, input_text):
if argv == ["crontab", "-l"]:
return state["text"], ""
if argv == ["crontab", "-"]:
state["text"] = input_text or ""
return "", ""
return 1, "", "unsupported"
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
job = backend.discover([Scope.USER])[0]
disabled = backend.plan_set_enabled(job, False)
assert disabled.action == "disable"
assert "# unrelated comment" in disabled.payload["new_text"]
assert "# schedls:disabled 0 2 * * * /usr/bin/backup /srv/data" in disabled.payload["new_text"]
assert backend.apply(disabled).changed is True
assert backend.discover([Scope.USER])[0].enabled is False
enabled = backend.plan_set_enabled(backend.discover([Scope.USER])[0], True)
assert enabled.action == "enable"
assert backend.apply(enabled).changed is True
assert state["text"] == original
def test_plan_set_enabled_is_noop_when_cron_job_already_has_requested_state(tmp_path, fake_runner) -> None:
text = "# schedls:begin name=backup\n0 2 * * * /usr/bin/backup\n# schedls:end name=backup\n"
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return text, ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
job = backend.discover([Scope.USER])[0]
plan = backend.plan_set_enabled(job, True)
result = backend.apply(plan)
assert plan.payload["mode"] == "noop"
assert result.changed is False
assert [call[0] for call in fake_runner.calls] == [
["crontab", "-l"],
["crontab", "-l"],
["crontab", "-l"],
]
def test_plan_set_enabled_rechecks_crontab_before_treating_request_as_a_noop(tmp_path, fake_runner) -> None:
active = "# schedls:begin name=backup\n0 2 * * * /usr/bin/backup\n# schedls:end name=backup\n"
disabled = active.replace("0 2 * * *", "# schedls:disabled 0 2 * * *")
state = {"text": active}
def crontab(argv, input_text):
if argv == ["crontab", "-l"]:
return state["text"], ""
if argv == ["crontab", "-"]:
state["text"] = input_text or ""
return "", ""
return 1, "", "unsupported"
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
stale_job = backend.discover([Scope.USER])[0]
state["text"] = disabled
plan = backend.plan_set_enabled(stale_job, True)
assert plan.payload["mode"] == "crontab"
assert backend.apply(plan).changed is True
assert state["text"] == active
def test_noop_set_enabled_refuses_if_crontab_changes_before_apply(tmp_path, fake_runner) -> None:
active = "# schedls:begin name=backup\n0 2 * * * /usr/bin/backup\n# schedls:end name=backup\n"
state = {"text": active}
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return state["text"], ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
plan = backend.plan_set_enabled(backend.discover([Scope.USER])[0], True)
state["text"] = active.replace("0 2 * * *", "# schedls:disabled 0 2 * * *")
with pytest.raises(SafetyRefusalError, match="crontab changed"):
backend.apply(plan)
def test_noop_set_enabled_refuses_if_system_drop_in_changes_before_apply(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
path = _write(
tmp_path / "etc/cron.d/schedls-backup",
renderer.render_system_file("backup", ["0 2 * * * root /usr/bin/backup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", lambda path, *, expected_uid: None)
plan = backend.plan_set_enabled(job, True)
path.write_text(renderer.render_system_file("backup", ["# schedls:disabled 0 2 * * * root /usr/bin/backup"]))
with pytest.raises(SafetyRefusalError, match="drop-in changed"):
backend.apply(plan)
def test_plan_set_enabled_system_cron_preserves_run_as(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
path = _write(
tmp_path / "etc/cron.d/schedls-backup",
renderer.render_system_file("backup", ["0 2 * * * www-data /usr/bin/backup /srv/data"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 0)
trusted_directories: list[tuple[str, int]] = []
def check_trusted_directory(path: str, *, expected_uid: int) -> None:
trusted_directories.append((path, expected_uid))
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", check_trusted_directory)
plan = backend.plan_set_enabled(job, False)
assert plan.action == "disable"
assert plan.files[0].path == str(path)
assert "# schedls:disabled 0 2 * * * www-data /usr/bin/backup /srv/data" in plan.files[0].content
assert trusted_directories == [(paths.cron_d, 0)]
assert CronBackend(fake_runner, paths).capabilities.enable is True
assert CronBackend(fake_runner, paths).capabilities.disable is True
def test_plan_set_enabled_system_cron_requires_root(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/cron.d/schedls-backup",
renderer.render_system_file("backup", ["0 2 * * * root /usr/bin/backup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 1000)
with pytest.raises(SafetyRefusalError):
backend.plan_set_enabled(job, False)
def test_plan_set_enabled_system_cron_refuses_unexpected_path(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/cron.d/schedls-backup",
renderer.render_system_file("backup", ["0 2 * * * root /usr/bin/backup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
unexpected_job = replace(job, source=replace(job.source, path=str(tmp_path / "unexpected")))
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", lambda path, *, expected_uid: None)
with pytest.raises(SafetyRefusalError, match="unexpected system cron file"):
backend.plan_set_enabled(unexpected_job, False)
def test_plan_update_disabled_system_cron_preserves_disabled_state(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/cron.d/schedls-backup",
renderer.render_system_file("backup", ["# schedls:disabled 0 2 * * * www-data /usr/bin/backup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", lambda path, *, expected_uid: None)
plan = backend.plan_update(
job,
_system_spec(name="backup", command=job.command, cron_expression="30 3 * * *"),
)
assert "# schedls:disabled 30 3 * * * www-data /usr/bin/backup" in plan.files[0].content
def test_plan_update_disabled_user_cron_preserves_disabled_state(tmp_path, fake_runner) -> None:
text = (
"# schedls:begin name=backup\n"
"# schedls:disabled 0 2 * * * /usr/bin/backup /srv/data\n"
"# schedls:end name=backup\n"
)
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return text, ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
job = backend.discover([Scope.USER])[0]
plan = backend.plan_update(
job,
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=job.command,
cron_expression="30 3 * * *",
),
)
assert "# schedls:disabled 30 3 * * * /usr/bin/backup /srv/data" in plan.payload["new_text"]
def test_discovers_periodic_directories(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
_write(tmp_path / "etc/cron.daily/logrotate", "#!/bin/sh\n", mode=0o755)
_write(tmp_path / "etc/cron.daily/README", "text\n", mode=0o644)
_write(tmp_path / "etc/cron.hourly/.hidden", "#!/bin/sh\n", mode=0o755)
jobs = CronBackend(fake_runner, paths).discover([Scope.SYSTEM])
assert [job.name for job in jobs] == ["logrotate"]
job = jobs[0]
assert job.schedule.expression == "@daily"
assert job.command.display() == paths.daily + "/logrotate"
assert job.cron.user == "root"
assert job.managed is False
assert "cron.daily" in job.source.detail
def test_spool_crontabs_require_root(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(tmp_path / "var/spool/cron/crontabs/alice", "5 4 * * * /usr/bin/alice\n")
_write(tmp_path / "var/spool/cron/crontabs/root", "5 4 * * * /usr/bin/root\n")
backend = CronBackend(fake_runner, paths)
monkeypatch.setattr(os, "geteuid", lambda: 1000)
assert backend.discover([Scope.USER]) == []
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr(CronBackend, "_current_user", staticmethod(lambda: "root"))
jobs = backend.discover([Scope.USER])
assert [job.name for job in jobs] == ["alice:1"]
assert jobs[0].scope is Scope.USER
assert jobs[0].cron.user == "alice"
assert jobs[0].source.path.endswith("crontabs/alice")
def test_spool_falls_back_to_flat_layout(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(tmp_path / "var/spool/cron/alice", "5 4 * * * /usr/bin/alice\n")
(tmp_path / "var/spool/cron/atjobs").mkdir(parents=True)
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr(CronBackend, "_current_user", staticmethod(lambda: "root"))
jobs = CronBackend(fake_runner, paths).discover([Scope.USER])
assert [job.name for job in jobs] == ["alice:1"]
def test_malformed_drop_in_is_unmanaged(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
_write(tmp_path / "etc/cron.d/schedls-broken", "0 2 * * * root /bin/true\n")
jobs = CronBackend(fake_runner, paths).discover([Scope.SYSTEM])
assert [job.name for job in jobs] == ["schedls-broken:1"]
assert jobs[0].managed is False
assert jobs[0].warnings
def test_collect_warns_when_malformed_drop_in_has_no_job_lines(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
path = _write(tmp_path / "etc/cron.d/schedls-broken", "")
monkeypatch.setattr(os, "geteuid", lambda: 0)
jobs, warnings = inspect_ops.collect([CronBackend(fake_runner, paths)], [Scope.SYSTEM])
assert jobs == []
assert warnings == [
f"cron discovery warning: {path} looks like a schedls drop-in but its managed block is missing "
"or malformed; treating its jobs as unmanaged."
]
def _system_spec(**overrides) -> JobSpec:
values = {
"name": "backup",
"backend": Backend.CRON,
"scope": Scope.SYSTEM,
"command": Command(argv=("/usr/bin/backup", "/srv/data")),
"cron_expression": "0 2 * * *",
"run_as": "www-data",
}
values.update(overrides)
return JobSpec(**values)
def test_plan_create_user_cron_with_execution_local_environment(tmp_path, fake_runner) -> None:
state = {"text": "# unrelated job\n0 1 * * * /usr/bin/unrelated\n"}
def crontab(argv, input_text):
if argv == ["crontab", "-l"]:
assert input_text is None
return state["text"], ""
if argv == ["crontab", "-"]:
state["text"] = input_text or ""
return "", ""
return 1, "", "unsupported"
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
plan = backend.plan_create(
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=Command(argv=("/usr/bin/backup", "/srv/data")),
cron_expression="0 2 * * *",
environment=(("GREETING", "hello world"), ("RATE", "100%"), ("MESSAGE", "50% off")),
)
)
assert "0 1 * * * /usr/bin/unrelated" in plan.payload["new_text"]
assert (
"0 2 * * * GREETING='hello world' RATE=100\\% MESSAGE='50\\% off' /usr/bin/backup /srv/data"
in plan.payload["new_text"]
)
assert "GREETING=" not in plan.payload["new_text"].split("# schedls:begin")[0]
assert backend.apply(plan).changed is True
jobs_by_name = {job.name: job for job in backend.discover([Scope.USER])}
backup = jobs_by_name["backup"]
assert backup.cron.environment == (("GREETING", "hello world"), ("RATE", "100%"), ("MESSAGE", "50% off"))
update_plan = backend.plan_update(
backup,
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=backup.command,
cron_expression="30 3 * * *",
),
)
assert (
"30 3 * * * GREETING='hello world' RATE=100\\% MESSAGE='50\\% off' /usr/bin/backup /srv/data"
in update_plan.payload["new_text"]
)
def test_plan_create_system_cron(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
(tmp_path / "etc/cron.d").mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", lambda path, *, expected_uid: None)
plan = CronBackend(fake_runner, paths).plan_create(_system_spec())
assert plan.payload["mode"] == "file"
assert plan.payload["expected_uid"] == 0
assert not plan.commands
change = plan.files[0]
assert change.path == paths.cron_d + "/schedls-backup"
assert "0 2 * * * www-data /usr/bin/backup /srv/data" in change.content
assert "# schedls:begin name=backup" in change.content
assert ("Run as", "www-data") in plan.summary
def test_plan_create_system_cron_with_execution_local_environment(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
(tmp_path / "etc/cron.d").mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", lambda path, *, expected_uid: None)
plan = CronBackend(fake_runner, paths).plan_create(
_system_spec(environment=(("GREETING", "hello world"), ("RATE", "100%")))
)
assert "0 2 * * * www-data GREETING='hello world' RATE=100\\% /usr/bin/backup /srv/data" in plan.files[0].content
assert "\nGREETING=" not in plan.files[0].content
def test_plan_create_system_cron_requires_root(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
monkeypatch.setattr(os, "geteuid", lambda: 1000)
with pytest.raises(SafetyRefusalError):
CronBackend(fake_runner, paths).plan_create(_system_spec())
def test_plan_create_system_cron_rejects_dotted_name(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
monkeypatch.setattr(os, "geteuid", lambda: 0)
with pytest.raises(InvalidScheduleError):
CronBackend(fake_runner, paths).plan_create(_system_spec(name="my.job"))
def test_plan_create_system_cron_conflict(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(tmp_path / "etc/cron.d/schedls-backup", "0 2 * * * root /bin/true\n")
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", lambda path, *, expected_uid: None)
with pytest.raises(ConflictError):
CronBackend(fake_runner, paths).plan_create(_system_spec())
def test_update_user_cron_preserves_existing_command(tmp_path, fake_runner) -> None:
document = CrontabDocument(
"# unrelated comment\n"
"# schedls:begin name=backup\n"
"0 2 * * * GREETING='hello world' /usr/bin/printf '50\\%s\\n'\n"
"# schedls:end name=backup\n"
)
state = {"text": document.text}
def crontab(argv, input_text):
if argv == ["crontab", "-l"]:
return state["text"], ""
if argv == ["crontab", "-"]:
state["text"] = input_text or ""
return "", ""
return 1, "", "unsupported"
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
job = backend.discover([Scope.USER])[0]
plan = backend.plan_update(
job,
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=job.command,
cron_expression="30 3 * * *",
),
)
assert plan.action == "update"
assert "# unrelated comment" in plan.payload["new_text"]
assert "30 3 * * * GREETING='hello world' /usr/bin/printf '50\\%s\\n'" in plan.payload["new_text"]
assert "50\\\\%" not in plan.payload["new_text"]
state["text"] = state["text"].replace("0 2 * * *", "15 2 * * *")
with pytest.raises(SafetyRefusalError, match="crontab changed"):
backend.apply(plan)
plan = backend.plan_update(
job,
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=job.command,
cron_expression="30 3 * * *",
),
)
result = backend.apply(plan)
assert result.changed
assert state["text"] == plan.payload["new_text"]
def test_update_disabled_user_cron_replaces_environment_without_reescaping_command(tmp_path, fake_runner) -> None:
state = {
"text": (
"# schedls:begin name=backup\n"
"# schedls:disabled 0 2 * * * OLD=old /usr/bin/printf '50\\%s\\n'\n"
"# schedls:end name=backup\n"
)
}
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return state["text"], ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
job = backend.discover([Scope.USER])[0]
plan = backend.plan_update(
job,
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=job.command,
cron_expression="0 2 * * *",
environment=(("MESSAGE", "50% off"),),
replace_environment=True,
),
)
assert "# schedls:disabled 0 2 * * * MESSAGE='50\\% off' /usr/bin/printf '50\\%s\\n'" in plan.payload["new_text"]
assert "OLD=old" not in plan.payload["new_text"]
assert "50\\\\%" not in plan.payload["new_text"]
def test_update_user_cron_clears_environment_without_reescaping_command(tmp_path, fake_runner) -> None:
state = {
"text": (
"# schedls:begin name=backup\n0 2 * * * OLD=old /usr/bin/printf '50\\%s\\n'\n# schedls:end name=backup\n"
)
}
def crontab(argv, input_text):
assert argv == ["crontab", "-l"]
assert input_text is None
return state["text"], ""
fake_runner.available.add("crontab")
fake_runner.handlers["crontab"] = crontab
backend = CronBackend(fake_runner, _make_paths(tmp_path))
job = backend.discover([Scope.USER])[0]
plan = backend.plan_update(
job,
JobSpec(
name="backup",
backend=Backend.CRON,
scope=Scope.USER,
command=job.command,
cron_expression="0 2 * * *",
environment=(),
replace_environment=True,
),
)
assert "0 2 * * * /usr/bin/printf '50\\%s\\n'" in plan.payload["new_text"]
assert "OLD=old" not in plan.payload["new_text"]
assert "50\\\\%" not in plan.payload["new_text"]
def test_plan_update_system_cron_preserves_run_as_and_other_content(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
path = _write(
tmp_path / "etc/cron.d/schedls-cleanup",
"# unrelated comment\n" + renderer.render_system_file("cleanup", ["30 3 * * 0 root /usr/bin/cleanup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 0)
trusted_directories: list[tuple[str, int]] = []
def check_trusted_directory(path: str, *, expected_uid: int) -> None:
trusted_directories.append((path, expected_uid))
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", check_trusted_directory)
plan = backend.plan_update(
job,
_system_spec(
name="cleanup",
command=Command(argv=("/usr/bin/cleanup", "--full")),
cron_expression="15 4 * * *",
run_as="www-data",
),
)
assert plan.action == "update"
assert plan.files[0].path == str(path)
assert "# unrelated comment" in plan.files[0].content
assert "15 4 * * * root /usr/bin/cleanup --full" in plan.files[0].content
assert "www-data" not in plan.files[0].content
assert trusted_directories == [(paths.cron_d, 0)]
def test_plan_update_system_cron_replaces_environment_without_reescaping_command(
tmp_path,
fake_runner,
monkeypatch,
) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/cron.d/schedls-cleanup",
renderer.render_system_file(
"cleanup",
["30 3 * * 0 root OLD=old /usr/bin/printf '50\\%s\\n'"],
),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 0)
monkeypatch.setattr("schedls.backends.cron.check_trusted_directory", lambda path, *, expected_uid: None)
plan = backend.plan_update(
job,
JobSpec(
name="cleanup",
backend=Backend.CRON,
scope=Scope.SYSTEM,
command=job.command,
cron_expression="30 3 * * 0",
environment=(("MESSAGE", "50% off"),),
replace_environment=True,
),
)
assert "30 3 * * 0 root MESSAGE='50\\% off' /usr/bin/printf '50\\%s\\n'" in plan.files[0].content
assert "OLD=old" not in plan.files[0].content
assert "50\\\\%" not in plan.files[0].content
def test_plan_update_system_cron_requires_root(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/cron.d/schedls-cleanup",
renderer.render_system_file("cleanup", ["30 3 * * 0 root /usr/bin/cleanup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 1000)
with pytest.raises(SafetyRefusalError):
backend.plan_update(job, _system_spec(name="cleanup"))
def test_apply_file_mode_create_and_remove(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
(tmp_path / "etc/cron.d").mkdir(parents=True, exist_ok=True)
backend = CronBackend(fake_runner, paths)
uid = os.getuid()
path = tmp_path / "etc/cron.d/schedls-backup"
content = renderer.render_system_file("backup", ["0 2 * * * root /usr/bin/backup"])
create = Plan(backend="cron", action="create")
create.files = [FileChange(path=str(path), content=content, mode=0o644, expected_uid=uid)]
create.payload = {
"mode": "file",
"action": "create",
"name": "backup",
"path": str(path),
"expected_uid": uid,
"snapshots": {str(path): None},
}
result = backend.apply(create)
assert result.changed
assert path.read_text() == content
updated_content = renderer.render_system_file("backup", ["30 3 * * * root /usr/bin/backup --full"])
update = Plan(backend="cron", action="update")
update.files = [FileChange(path=str(path), content=updated_content, mode=0o644, expected_uid=uid)]
update.payload = {
"mode": "file",
"action": "update",
"name": "backup",
"path": str(path),
"expected_uid": uid,
"snapshots": {str(path): content},
"expected_line": "30 3 * * * root /usr/bin/backup --full",
}
result = backend.apply(update)
assert result.changed
assert result.messages == ["System cron drop-in updated."]
assert path.read_text() == updated_content
remove = Plan(backend="cron", action="remove")
remove.files = [FileChange(path=str(path), content=None, expected_uid=uid)]
remove.payload = {
"mode": "file",
"action": "remove",
"name": "backup",
"path": str(path),
"expected_uid": uid,
"snapshots": {str(path): updated_content},
}
result = backend.apply(remove)
assert result.changed
assert not path.exists()
def test_apply_file_mode_verifies_disabled_cron_job(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
path = _write(
tmp_path / "etc/cron.d/schedls-backup",
renderer.render_system_file("backup", ["0 2 * * * root /usr/bin/backup"]),
)
disabled_content = renderer.render_system_file("backup", ["# schedls:disabled 0 2 * * * root /usr/bin/backup"])
plan = Plan(backend="cron", action="disable")
plan.files = [FileChange(path=str(path), content=disabled_content, expected_uid=os.getuid())]
plan.payload = {
"mode": "file",
"action": "disable",
"name": "backup",
"path": str(path),
"expected_uid": os.getuid(),
"snapshots": {str(path): path.read_text()},
"expected_line": "# schedls:disabled 0 2 * * * root /usr/bin/backup",
}
result = CronBackend(fake_runner, paths).apply(plan)
assert result.changed is True
assert result.messages == ["System cron job disabled."]
assert path.read_text() == disabled_content
def test_apply_file_update_refuses_changed_snapshot(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
_write(tmp_path / "etc/cron.d/schedls-backup", "changed\n")
backend = CronBackend(fake_runner, paths)
path = tmp_path / "etc/cron.d/schedls-backup"
plan = Plan(backend="cron", action="update")
plan.files = [FileChange(path=str(path), content="new content\n", expected_uid=os.getuid())]
plan.payload = {
"mode": "file",
"action": "update",
"name": "backup",
"path": str(path),
"expected_uid": os.getuid(),
"snapshots": {str(path): "expected\n"},
}
with pytest.raises(SafetyRefusalError):
backend.apply(plan)
assert path.exists()
def test_apply_file_mode_rolls_back_on_failure(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
(tmp_path / "etc/cron.d").mkdir(parents=True, exist_ok=True)
backend = CronBackend(fake_runner, paths)
uid = os.getuid()
first = tmp_path / "etc/cron.d/schedls-one"
second = tmp_path / "etc/cron.d/schedls-two"
_write(second, "conflict\n")
plan = Plan(backend="cron", action="create")
plan.files = [
FileChange(path=str(first), content=renderer.render_system_file("one", ["0 2 * * * root /bin/true"])),
FileChange(path=str(second), content=renderer.render_system_file("two", ["0 3 * * * root /bin/true"])),
]
plan.payload = {
"mode": "file",
"action": "create",
"name": "one",
"path": str(first),
"expected_uid": uid,
"snapshots": {str(first): None, str(second): None},
}
with pytest.raises(SafetyRefusalError):
backend.apply(plan)
assert not first.exists()
def test_plan_remove_system_cron(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/cron.d/schedls-cleanup",
renderer.render_system_file("cleanup", ["30 3 * * 0 root /usr/bin/cleanup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 0)
plan = backend.plan_remove(job)
assert plan.payload["mode"] == "file"
assert plan.files[0].path == paths.cron_d + "/schedls-cleanup"
assert plan.files[0].content is None
def test_plan_remove_system_cron_requires_root(tmp_path, fake_runner, monkeypatch) -> None:
paths = _make_paths(tmp_path)
_write(
tmp_path / "etc/cron.d/schedls-cleanup",
renderer.render_system_file("cleanup", ["30 3 * * 0 root /usr/bin/cleanup"]),
)
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
monkeypatch.setattr(os, "geteuid", lambda: 1000)
with pytest.raises(SafetyRefusalError):
backend.plan_remove(job)
def test_plan_remove_refuses_unmanaged(tmp_path, fake_runner) -> None:
paths = _make_paths(tmp_path)
_write(tmp_path / "etc/cron.d/backup", "0 2 * * * root /usr/bin/backup\n")
backend = CronBackend(fake_runner, paths)
job = backend.discover([Scope.SYSTEM])[0]
with pytest.raises(SafetyRefusalError):
backend.plan_remove(job)
with pytest.raises(SafetyRefusalError):
backend.plan_set_enabled(job, False)