356 lines
14 KiB
Python
356 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
|
|
import pytest
|
|
|
|
from schedls.cli import _spec_for_edit, _spec_for_new, _wizard_edit, _wizard_new, build_parser
|
|
from schedls.errors import ConfirmationRequiredError, InvalidScheduleError
|
|
from schedls.models import (
|
|
Backend,
|
|
Command,
|
|
CronDetails,
|
|
JobSource,
|
|
Schedule,
|
|
ScheduledJob,
|
|
ScheduleKind,
|
|
Scope,
|
|
SystemdDetails,
|
|
)
|
|
from schedls.output import Output
|
|
from schedls.prompt import Prompter
|
|
|
|
|
|
class FakeInput:
|
|
def __init__(self, answers: list[str]) -> None:
|
|
self.answers = list(answers)
|
|
self.prompts: list[str] = []
|
|
|
|
def __call__(self, label: str) -> str:
|
|
self.prompts.append(label)
|
|
if not self.answers:
|
|
raise EOFError
|
|
return self.answers.pop(0)
|
|
|
|
|
|
class FakeStdin:
|
|
def __init__(self, *, isatty: bool) -> None:
|
|
self._isatty = isatty
|
|
|
|
def isatty(self) -> bool:
|
|
return self._isatty
|
|
|
|
|
|
def make_prompter(answers: list[str], *, calendar_validator=None) -> tuple[Prompter, FakeInput]:
|
|
fake = FakeInput(answers)
|
|
output = Output(stdout=io.StringIO(), stderr=io.StringIO())
|
|
return Prompter(output=output, input_fn=fake, calendar_validator=calendar_validator), fake
|
|
|
|
|
|
def _systemd_job(*, environment: tuple[tuple[str, str], ...] = ()) -> ScheduledJob:
|
|
return ScheduledJob(
|
|
name="backup",
|
|
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(detail="systemd user timer"),
|
|
systemd=SystemdDetails(on_calendar=("*-*-* 02:00:00",), environment=environment),
|
|
)
|
|
|
|
|
|
def _cron_job(*, environment: tuple[tuple[str, str], ...] = ()) -> ScheduledJob:
|
|
return ScheduledJob(
|
|
name="cleanup",
|
|
backend=Backend.CRON,
|
|
scope=Scope.USER,
|
|
managed=True,
|
|
enabled=None,
|
|
schedule=Schedule(ScheduleKind.CRON, "0 2 * * *"),
|
|
command=Command(raw="/usr/local/bin/cleanup"),
|
|
source=JobSource(detail="current user's crontab"),
|
|
cron=CronDetails(expression="0 2 * * *", environment=environment),
|
|
)
|
|
|
|
|
|
def test_text_requires_a_value() -> None:
|
|
prompter, fake = make_prompter(["", "backup"])
|
|
assert prompter.text("Name") == "backup"
|
|
assert len(fake.prompts) == 2
|
|
|
|
|
|
def test_text_uses_default_on_blank() -> None:
|
|
prompter, _ = make_prompter([""])
|
|
assert prompter.text("Name", default="backup") == "backup"
|
|
|
|
|
|
def test_optional_text() -> None:
|
|
prompter, _ = make_prompter(["", ""])
|
|
assert prompter.optional_text("Jitter") is None
|
|
assert prompter.optional_text("Jitter", default="5min") == "5min"
|
|
|
|
|
|
def test_yes_no_rejects_unknown_answers() -> None:
|
|
prompter, _ = make_prompter(["maybe", "yes"])
|
|
assert prompter.yes_no("Continue?") is True
|
|
|
|
|
|
def test_choice_by_number_and_default() -> None:
|
|
prompter, _ = make_prompter(["9", "2", ""])
|
|
options = [("daily", "Daily"), ("weekly", "Weekly")]
|
|
assert prompter.choice("Schedule:", options, default="daily") == "weekly"
|
|
assert prompter.choice("Schedule:", options, default="daily") == "daily"
|
|
|
|
|
|
def test_command_builds_argv() -> None:
|
|
prompter, _ = make_prompter(["n", "/usr/bin/echo hello world"])
|
|
command = prompter.command()
|
|
assert command.shell is False
|
|
assert command.argv == ("/usr/bin/echo", "hello", "world")
|
|
|
|
|
|
def test_command_shell_mode() -> None:
|
|
prompter, _ = make_prompter(["y", "echo hi | cat"])
|
|
command = prompter.command()
|
|
assert command.shell is True
|
|
assert command.raw == "echo hi | cat"
|
|
|
|
|
|
def test_environment_collects_valid_entries() -> None:
|
|
prompter, _ = make_prompter(["A=1", "1BAD=2", "B=x=y", ""])
|
|
assert prompter.environment() == ["A=1", "B=x=y"]
|
|
|
|
|
|
def test_schedule_daily_reprompts_on_bad_time() -> None:
|
|
prompter, _ = make_prompter(["", "25:00", "02:30"])
|
|
assert prompter.schedule(Backend.SYSTEMD) == {"daily": "02:30"}
|
|
|
|
|
|
def test_schedule_weekly_and_monthly() -> None:
|
|
prompter, _ = make_prompter(["3", "Mon", "02:00"])
|
|
assert prompter.schedule(Backend.SYSTEMD) == {"weekly": ["Mon", "02:00"]}
|
|
prompter, _ = make_prompter(["4", "15", "06:00"])
|
|
assert prompter.schedule(Backend.SYSTEMD) == {"monthly": ["15", "06:00"]}
|
|
|
|
|
|
def test_schedule_custom_systemd_validates() -> None:
|
|
def validator(expression: str) -> None:
|
|
if expression != "daily":
|
|
raise InvalidScheduleError("bad expression")
|
|
|
|
prompter, _ = make_prompter(["5", "bogus", "daily"], calendar_validator=validator)
|
|
assert prompter.schedule(Backend.SYSTEMD) == {"calendar": ["daily"]}
|
|
|
|
|
|
def test_schedule_custom_cron() -> None:
|
|
prompter, _ = make_prompter(["5", "0 4 * * *"])
|
|
assert prompter.schedule(Backend.CRON) == {"cron_expr": "0 4 * * *"}
|
|
|
|
|
|
def test_require_terminal_rejects_non_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=False))
|
|
prompter, _ = make_prompter([])
|
|
with pytest.raises(ConfirmationRequiredError):
|
|
prompter.require_terminal()
|
|
|
|
|
|
def test_eof_raises_confirmation_required() -> None:
|
|
prompter, _ = make_prompter([])
|
|
with pytest.raises(ConfirmationRequiredError):
|
|
prompter.text("Name")
|
|
|
|
|
|
def test_wizard_new_systemd(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "-i"])
|
|
answers = ["backup", "", "", "n", "/usr/local/bin/backup /srv/data", "", "02:00", "n", "n"]
|
|
prompter, _ = make_prompter(answers)
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.name == "backup"
|
|
assert spec.backend is Backend.SYSTEMD
|
|
assert spec.scope is Scope.USER
|
|
assert spec.calendar == ("*-*-* 02:00:00",)
|
|
assert spec.command.argv == ("/usr/local/bin/backup", "/srv/data")
|
|
|
|
|
|
def test_wizard_new_cron(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "-i"])
|
|
answers = ["cleanup", "2", "", "n", "/usr/local/bin/cleanup", "", "04:00", "n"]
|
|
prompter, _ = make_prompter(answers)
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.backend is Backend.CRON
|
|
assert spec.scope is Scope.USER
|
|
assert spec.cron_expression == "0 4 * * *"
|
|
assert spec.run_as is None
|
|
|
|
|
|
def test_wizard_new_cron_collects_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "cleanup", "-i", "--cron", "--daily", "04:00"])
|
|
prompter, _ = make_prompter(["", "n", "/usr/local/bin/cleanup", "y", "GREETING=hello world", ""])
|
|
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
|
|
assert spec.environment == (("GREETING", "hello world"),)
|
|
|
|
|
|
def test_wizard_new_skips_provided_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00", "--user"])
|
|
prompter, fake = make_prompter(["n", "n"])
|
|
args, tail = _wizard_new(args, ["/bin/true"], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.calendar == ("*-*-* 02:00:00",)
|
|
assert fake.prompts == ["Set advanced timer options? [y/N] ", "Add environment variables? [y/N] "]
|
|
|
|
|
|
def test_wizard_new_scope_prompt_defaults_to_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "cleanup", "-i", "--cron", "--daily", "04:00"])
|
|
prompter, fake = make_prompter(["", "n", "/usr/local/bin/cleanup", "n"])
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.scope is Scope.USER
|
|
assert spec.run_as is None
|
|
assert any(prompt.startswith("Scope:") for prompt in fake.prompts)
|
|
assert not any(prompt.startswith("Run as user") for prompt in fake.prompts)
|
|
|
|
|
|
def test_wizard_new_system_cron_prompts_run_as(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
|
prompter, _ = make_prompter(["2", "www-data", "n", "/usr/local/bin/report", "n"])
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.scope is Scope.SYSTEM
|
|
assert spec.run_as == "www-data"
|
|
|
|
|
|
def test_wizard_new_system_cron_run_as_defaults_to_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
|
prompter, _ = make_prompter(["2", "", "n", "/usr/local/bin/report", "n"])
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.scope is Scope.SYSTEM
|
|
assert spec.run_as == "root"
|
|
|
|
|
|
def test_wizard_new_system_cron_run_as_reprompts(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
|
prompter, fake = make_prompter(["2", "bad user", "www-data", "n", "/usr/local/bin/report", "n"])
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.run_as == "www-data"
|
|
assert fake.prompts.count("Run as user [root]: ") == 2
|
|
|
|
|
|
def test_wizard_new_provided_scope_and_run_as_are_not_reasked(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(
|
|
["new", "report", "-i", "--cron", "--system", "--run-as", "root", "--daily", "04:00"]
|
|
)
|
|
prompter, fake = make_prompter(["n", "/usr/local/bin/report", "n"])
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.scope is Scope.SYSTEM
|
|
assert spec.run_as == "root"
|
|
assert all("Scope:" not in prompt and "Run as user" not in prompt for prompt in fake.prompts)
|
|
|
|
|
|
def test_wizard_new_systemd_scope_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00"])
|
|
prompter, _ = make_prompter(["2", "n", "/usr/local/bin/backup", "n", "n"])
|
|
args, tail = _wizard_new(args, [], prompter)
|
|
spec = _spec_for_new(args, tail)
|
|
assert spec.scope is Scope.SYSTEM
|
|
assert spec.calendar == ("*-*-* 02:00:00",)
|
|
|
|
|
|
def test_wizard_edit_changes_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
job = _systemd_job()
|
|
args = build_parser().parse_args(["edit", "backup", "-i"])
|
|
answers = ["n", "y", "", "03:00", "n", "n"]
|
|
prompter, _ = make_prompter(answers)
|
|
args, tail = _wizard_edit(job, args, [], prompter)
|
|
spec = _spec_for_edit(job, args, tail)
|
|
assert spec.calendar == ("*-*-* 03:00:00",)
|
|
|
|
|
|
def test_wizard_edit_changes_cron_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
args = build_parser().parse_args(["edit", "cleanup", "-i"])
|
|
prompter, _ = make_prompter(["n", "y", "", "03:00", "n"])
|
|
args, tail = _wizard_edit(_cron_job(), args, [], prompter)
|
|
spec = _spec_for_edit(_cron_job(), args, tail)
|
|
|
|
assert spec.cron_expression == "0 3 * * *"
|
|
|
|
|
|
def test_wizard_edit_cron_can_clear_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
job = _cron_job(environment=(("OLD", "value"),))
|
|
args = build_parser().parse_args(["edit", "cleanup", "-i"])
|
|
prompter, _ = make_prompter(["n", "n", "y", ""])
|
|
|
|
args, tail = _wizard_edit(job, args, [], prompter)
|
|
spec = _spec_for_edit(job, args, tail)
|
|
|
|
assert args.clear_env is True
|
|
assert spec.environment == ()
|
|
|
|
|
|
def test_wizard_edit_cron_replaces_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
job = _cron_job(environment=(("OLD", "value"),))
|
|
args = build_parser().parse_args(["edit", "cleanup", "-i"])
|
|
prompter, _ = make_prompter(["n", "n", "y", "NEW=value", ""])
|
|
|
|
args, tail = _wizard_edit(job, args, [], prompter)
|
|
spec = _spec_for_edit(job, args, tail)
|
|
|
|
assert spec.environment == (("NEW", "value"),)
|
|
assert spec.replace_environment is True
|
|
|
|
|
|
def test_wizard_edit_systemd_can_clear_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
job = _systemd_job(environment=(("OLD", "value"),))
|
|
args = build_parser().parse_args(["edit", "backup", "-i"])
|
|
prompter, _ = make_prompter(["n", "n", "n", "y", ""])
|
|
|
|
args, tail = _wizard_edit(job, args, [], prompter)
|
|
spec = _spec_for_edit(job, args, tail)
|
|
|
|
assert args.clear_env is True
|
|
assert spec.environment == ()
|
|
|
|
|
|
def test_wizard_edit_keeps_changes_optional(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
|
job = _systemd_job()
|
|
args = build_parser().parse_args(["edit", "backup", "-i"])
|
|
prompter, _ = make_prompter(["n", "n", "n", "n"])
|
|
args, tail = _wizard_edit(job, args, [], prompter)
|
|
spec = _spec_for_edit(job, args, tail)
|
|
assert spec.calendar == ("*-*-* 02:00:00",)
|
|
assert tail == []
|
|
|
|
|
|
def test_spec_for_edit_systemd_clears_environment() -> None:
|
|
args = build_parser().parse_args(["edit", "backup", "--clear-env"])
|
|
|
|
spec = _spec_for_edit(_systemd_job(environment=(("OLD", "value"),)), args, [])
|
|
|
|
assert spec.environment == ()
|
|
assert spec.replace_environment is True
|