Add managed cron lifecycle and environment support
This commit is contained in:
20 files changed
+1457
-93
No files matched your search
@@ -114,8 +114,13 @@ Create a cron job:
|
|||||||
|
|
||||||
```console
|
```console
|
||||||
$ schedls new cleanup --cron --cron-expr '0 4 * * 0' -- /usr/local/bin/cleanup
|
$ schedls new cleanup --cron --cron-expr '0 4 * * 0' -- /usr/local/bin/cleanup
|
||||||
|
$ schedls new backup --cron --daily 02:00 --env TZ=UTC -- /usr/local/bin/backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Cron `--env` values apply only to that managed job; they are scheduler
|
||||||
|
configuration rather than secret storage. Managed cron jobs can also be enabled
|
||||||
|
or disabled without deleting their saved schedule.
|
||||||
|
|
||||||
Create a system cron drop-in as root (runs as `root` unless `--run-as` is given):
|
Create a system cron drop-in as root (runs as `root` unless `--run-as` is given):
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Before every release:
|
|||||||
[ ] No pager/editor invocation under privilege
|
[ ] No pager/editor invocation under privilege
|
||||||
[ ] Unit serialization tests pass
|
[ ] Unit serialization tests pass
|
||||||
[ ] Cron serialization tests pass
|
[ ] Cron serialization tests pass
|
||||||
|
[ ] Managed cron state and environment parsing tests pass
|
||||||
[ ] Symlink tests pass
|
[ ] Symlink tests pass
|
||||||
[ ] Atomic-write tests pass
|
[ ] Atomic-write tests pass
|
||||||
[ ] Trusted-directory ownership/mode tests pass
|
[ ] Trusted-directory ownership/mode tests pass
|
||||||
@@ -49,4 +50,5 @@ Before every release:
|
|||||||
[ ] New mutating operations support --dry-run
|
[ ] New mutating operations support --dry-run
|
||||||
[ ] New mutating operations have clear confirmation behavior
|
[ ] New mutating operations have clear confirmation behavior
|
||||||
[ ] Documentation describes new filesystem/state changes
|
[ ] Documentation describes new filesystem/state changes
|
||||||
|
[ ] Environment values are documented as non-secret scheduler configuration
|
||||||
```
|
```
|
||||||
@@ -98,7 +98,10 @@ time to detect concurrent edits.
|
|||||||
## JSON schema
|
## JSON schema
|
||||||
|
|
||||||
`schedls --json` emits a single document on stdout; diagnostics go to stderr.
|
`schedls --json` emits a single document on stdout; diagnostics go to stderr.
|
||||||
The schema version is `1`.
|
The schema version is `1`. Its field names are a compatibility contract: this
|
||||||
|
release adds no JSON keys or schema version. Managed cron jobs use the existing
|
||||||
|
`enabled` field (`true` for active, `false` for disabled); unmanaged or unknown
|
||||||
|
cron state remains `null`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -128,3 +131,7 @@ field names; missing data is `null`, never invented. Jobs whose `backend` is
|
|||||||
`cron` additionally carry a `cron` object (`expression`, `shell`, `mailto`,
|
`cron` additionally carry a `cron` object (`expression`, `shell`, `mailto`,
|
||||||
`line`, `user`, `environment`), where `user` is the run-as account for system
|
`line`, `user`, `environment`), where `user` is the run-as account for system
|
||||||
entries and `null` for the current user's crontab.
|
entries and `null` for the current user's crontab.
|
||||||
|
|
||||||
|
Managed cron environments use that existing `cron.environment` array. No
|
||||||
|
environment values are added to unrelated cron entries or to a file-wide
|
||||||
|
document field.
|
||||||
+19
-6
@@ -127,6 +127,9 @@ everything else keeps its current value.
|
|||||||
Passing `--` without `--command` is an error, so the command can never be
|
Passing `--` without `--command` is an error, so the command can never be
|
||||||
changed by accident.
|
changed by accident.
|
||||||
- `--no-persistent` clears `Persistent=` on a systemd timer.
|
- `--no-persistent` clears `Persistent=` on a systemd timer.
|
||||||
|
- `--env` supplied during an edit replaces the complete environment. Omitting it
|
||||||
|
preserves the current values; `--clear-env` removes every value instead.
|
||||||
|
`--env` and `--clear-env` cannot be combined.
|
||||||
- Editing is supported for schedls-managed systemd timers and cron jobs. A cron
|
- Editing is supported for schedls-managed systemd timers and cron jobs. A cron
|
||||||
edit changes only the job line in its managed block and preserves every other
|
edit changes only the job line in its managed block and preserves every other
|
||||||
cron line. Editing a system cron drop-in requires root and preserves its
|
cron line. Editing a system cron drop-in requires root and preserves its
|
||||||
@@ -150,9 +153,9 @@ $ schedls enable backup
|
|||||||
$ schedls disable backup
|
$ schedls disable backup
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable or disable a systemd timer. Cron has no portable enabled/disabled
|
Enable or disable a systemd timer or a schedls-managed cron job. For cron,
|
||||||
concept, so these commands explain that limitation and exit without changing
|
`schedls` comments or restores the managed job line without changing its
|
||||||
anything.
|
schedule or command; unmanaged cron jobs remain immutable.
|
||||||
|
|
||||||
### `logs NAME`
|
### `logs NAME`
|
||||||
|
|
||||||
@@ -241,7 +244,8 @@ and [cron.md](cron.md#schedule-shortcuts).
|
|||||||
| `--jitter DURATION` | systemd only | `--jitter 5min` |
|
| `--jitter DURATION` | systemd only | `--jitter 5min` |
|
||||||
| `--accuracy DURATION` | systemd only | `--accuracy 1min` |
|
| `--accuracy DURATION` | systemd only | `--accuracy 1min` |
|
||||||
| `--working-directory PATH` | systemd only | `--working-directory /srv/data` |
|
| `--working-directory PATH` | systemd only | `--working-directory /srv/data` |
|
||||||
| `--env KEY=VALUE` | systemd only | `--env TZ=UTC` (repeatable) |
|
| `--env KEY=VALUE` | both | `--env TZ=UTC` (repeatable) |
|
||||||
|
| `--clear-env` | both, edit only| remove every environment value |
|
||||||
| `--shell SCRIPT` | both | `--shell 'echo hi \| tee /tmp/log'` |
|
| `--shell SCRIPT` | both | `--shell 'echo hi \| tee /tmp/log'` |
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -253,8 +257,14 @@ Notes:
|
|||||||
`--cron-expr` with `--timer`, or `--persistent` with `--cron`) instead of
|
`--cron-expr` with `--timer`, or `--persistent` with `--cron`) instead of
|
||||||
being silently ignored.
|
being silently ignored.
|
||||||
- Cron accepts a single schedule.
|
- Cron accepts a single schedule.
|
||||||
- Cron environment variables are not supported yet; passing `--env` with
|
- `--env` may be repeated. On `new`, it sets the complete environment. On
|
||||||
`--cron` is rejected rather than silently ignored.
|
`edit`, omitting it preserves the current environment, while supplying it
|
||||||
|
replaces every value. `--clear-env` explicitly selects an empty environment;
|
||||||
|
it is valid only on `edit` and cannot be combined with `--env`.
|
||||||
|
- For cron, values are execution-local shell assignments on the managed job
|
||||||
|
line, never file-wide crontab assignments. In system cron, they follow the
|
||||||
|
run-as account. See [cron command rendering](cron.md#command-rendering) for
|
||||||
|
`--shell` pipeline scope.
|
||||||
- Cron jobs are created for the current user by default. With `--system` (root
|
- Cron jobs are created for the current user by default. With `--system` (root
|
||||||
only), schedls creates a system cron drop-in; use `--run-as USER` to choose
|
only), schedls creates a system cron drop-in; use `--run-as USER` to choose
|
||||||
the account that runs it.
|
the account that runs it.
|
||||||
@@ -291,6 +301,9 @@ $ schedls edit backup -i
|
|||||||
- `new` asks for the scope (user or system) unless `--user`/`--system` was
|
- `new` asks for the scope (user or system) unless `--user`/`--system` was
|
||||||
given. Choosing system for a cron job also asks which user it should run as
|
given. Choosing system for a cron job also asks which user it should run as
|
||||||
(`--run-as`, default `root`).
|
(`--run-as`, default `root`).
|
||||||
|
- The wizard can add environment values when creating a job and replace them
|
||||||
|
when editing one. Choosing replacement and entering no values clears the
|
||||||
|
environment.
|
||||||
- A terminal is required. With piped or redirected input, `schedls` exits `3`
|
- A terminal is required. With piped or redirected input, `schedls` exits `3`
|
||||||
and asks you to pass flags instead.
|
and asks you to pass flags instead.
|
||||||
- Interactive mode cannot be combined with `--json` (exit `2`).
|
- Interactive mode cannot be combined with `--json` (exit `2`).
|
||||||
|
|||||||
+58
-2
@@ -58,6 +58,19 @@ block:
|
|||||||
If markers are malformed or a `begin` has no matching `end`, mutations refuse
|
If markers are malformed or a `begin` has no matching `end`, mutations refuse
|
||||||
to proceed rather than guess.
|
to proceed rather than guess.
|
||||||
|
|
||||||
|
Existing active blocks need no migration. A disabled managed job keeps the same
|
||||||
|
block and replaces its job line with a column-zero comment:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# schedls:begin name=backup
|
||||||
|
# schedls:disabled 0 2 * * * /usr/local/bin/backup /srv/data
|
||||||
|
# schedls:end name=backup
|
||||||
|
```
|
||||||
|
|
||||||
|
The literal `# schedls:disabled ` prefix is the disabled representation; the
|
||||||
|
unprefixed line is enabled. This keeps the job discoverable and reversible
|
||||||
|
without giving cron a new file-wide setting.
|
||||||
|
|
||||||
## Editing
|
## Editing
|
||||||
|
|
||||||
`schedls edit` supports jobs in valid schedls-managed blocks. It updates only
|
`schedls edit` supports jobs in valid schedls-managed blocks. It updates only
|
||||||
@@ -69,6 +82,11 @@ When only the schedule changes, schedls retains the existing cron command
|
|||||||
without reverse-parsing or re-quoting shell syntax. Replacing a command uses
|
without reverse-parsing or re-quoting shell syntax. Replacing a command uses
|
||||||
the normal safe command renderer.
|
the normal safe command renderer.
|
||||||
|
|
||||||
|
When an environment is replaced or cleared, schedls preserves the existing raw
|
||||||
|
command field and replaces only its managed assignment prefix. A malformed or
|
||||||
|
non-canonical managed prefix produces a warning and makes mutation refuse
|
||||||
|
rather than guess at shell syntax.
|
||||||
|
|
||||||
For a system drop-in, editing requires root and re-checks the expected
|
For a system drop-in, editing requires root and re-checks the expected
|
||||||
`/etc/cron.d/schedls-<name>` path, ownership, trusted directory, and file
|
`/etc/cron.d/schedls-<name>` path, ownership, trusted directory, and file
|
||||||
snapshot before atomically replacing the file. The existing `run-as` user is
|
snapshot before atomically replacing the file. The existing `run-as` user is
|
||||||
@@ -98,6 +116,32 @@ Use `--run-as USER` to run a system job as another account (default `root`).
|
|||||||
The interactive wizard (`schedls new -i`) asks for the scope, and choosing the
|
The interactive wizard (`schedls new -i`) asks for the scope, and choosing the
|
||||||
system scope for a cron job then prompts for the run-as user.
|
system scope for a cron job then prompts for the run-as user.
|
||||||
|
|
||||||
|
## Per-job environment
|
||||||
|
|
||||||
|
`--env KEY=VALUE` creates execution-local POSIX shell assignments on a managed
|
||||||
|
job line. They are not file-wide crontab environment declarations, so they do
|
||||||
|
not affect unrelated jobs. A user job is rendered like this:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 2 * * * TZ=UTC /usr/local/bin/backup
|
||||||
|
```
|
||||||
|
|
||||||
|
For a system job, the run-as account stays before the assignments:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 2 * * * backup TZ=UTC /usr/local/bin/backup
|
||||||
|
```
|
||||||
|
|
||||||
|
`--env` may be repeated. On creation it supplies the complete environment. On
|
||||||
|
edit, omitting it preserves the current values; supplying one or more entries
|
||||||
|
replaces the complete set; and `--clear-env` explicitly removes every value.
|
||||||
|
`--env` and `--clear-env` are mutually exclusive. The interactive wizard offers
|
||||||
|
the same choices, including an empty replacement to clear the environment.
|
||||||
|
|
||||||
|
Environment values are shell-quoted and percent-escaped with the command field.
|
||||||
|
They are scheduler configuration, not secret storage: current-user crontabs and
|
||||||
|
system cron drop-ins may be readable by other users.
|
||||||
|
|
||||||
## Command rendering
|
## Command rendering
|
||||||
|
|
||||||
Cron executes command text through a shell. `schedls` keeps an argv internally
|
Cron executes command text through a shell. `schedls` keeps an argv internally
|
||||||
@@ -113,6 +157,11 @@ representation exists.
|
|||||||
Raw shell syntax is available explicitly with `--shell`. `schedls` never infers
|
Raw shell syntax is available explicitly with `--shell`. `schedls` never infers
|
||||||
shell mode from characters such as `|`, `>`, `&&` or `$()`.
|
shell mode from characters such as `|`, `>`, `&&` or `$()`.
|
||||||
|
|
||||||
|
For cron `--shell`, a managed environment is an ordinary leading POSIX
|
||||||
|
assignment. In a pipeline such as `TZ=UTC producer | consumer`, it applies to
|
||||||
|
the first command only; schedls does not add a wrapper to widen the assignment's
|
||||||
|
scope. Export a value in the script when later pipeline commands need it.
|
||||||
|
|
||||||
## Schedule shortcuts
|
## Schedule shortcuts
|
||||||
|
|
||||||
| Flag | Example | Compiles to |
|
| Flag | Example | Compiles to |
|
||||||
@@ -147,8 +196,15 @@ CronCapabilities(
|
|||||||
|
|
||||||
## Enable / disable
|
## Enable / disable
|
||||||
|
|
||||||
Cron has no universal native enabled/disabled concept. `schedls enable` and
|
`schedls enable` and `schedls disable` support schedls-managed cron jobs using
|
||||||
`schedls disable` support systemd only and explain this limitation for cron.
|
the disabled-line representation above. They are idempotent and only add or
|
||||||
|
remove that prefix, preserving the saved schedule and command text exactly.
|
||||||
|
Unmanaged cron jobs are never changed.
|
||||||
|
|
||||||
|
Older schedls versions do not understand the disabled-line representation. They
|
||||||
|
will leave its job inactive because cron treats it as a comment, but cannot edit
|
||||||
|
that block correctly. Re-enable the job with a version that supports this
|
||||||
|
format before downgrading if you need to manage it with an older release.
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ of JSON encoding.
|
|||||||
Helper commands run with a controlled environment (`LC_ALL=C`,
|
Helper commands run with a controlled environment (`LC_ALL=C`,
|
||||||
`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and never with a shell.
|
`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and never with a shell.
|
||||||
Pagers and editors are never launched while `schedls` is running. Environment
|
Pagers and editors are never launched while `schedls` is running. Environment
|
||||||
values passed with `--env` are written to unit files with systemd quoting and
|
values passed with `--env` are written to unit files with systemd quoting or to
|
||||||
are world-readable like any other unit; do not put secrets in them.
|
managed cron command prefixes with shell quoting. They are scheduler
|
||||||
|
configuration that may be readable by other users; do not put secrets in them.
|
||||||
|
|
||||||
### TOCTOU races
|
### TOCTOU races
|
||||||
|
|
||||||
@@ -89,11 +90,12 @@ practical.
|
|||||||
|
|
||||||
### Corrupt or malicious existing configuration
|
### Corrupt or malicious existing configuration
|
||||||
|
|
||||||
Configuration is treated as opaque data. Malformed `schedls` cron markers cause
|
Configuration is treated as opaque data. Malformed `schedls` cron markers or
|
||||||
mutation to fail closed rather than guess. Unknown crontab lines are preserved
|
managed environment prefixes cause mutation to fail closed rather than guess.
|
||||||
byte-for-byte. A systemd unit whose name starts with `schedls-` is only treated
|
Unknown crontab lines are preserved byte-for-byte. A systemd unit whose name
|
||||||
as managed when its file carries the `# Managed by schedls` and `# Name:`
|
starts with `schedls-` is only treated as managed when its file carries the
|
||||||
markers, so a same-named foreign unit is not modified. Likewise a
|
`# Managed by schedls` and `# Name:` markers, so a same-named foreign unit is
|
||||||
|
not modified. Likewise a
|
||||||
`/etc/cron.d/schedls-<name>` drop-in is only managed when its begin/end markers
|
`/etc/cron.d/schedls-<name>` drop-in is only managed when its begin/end markers
|
||||||
match the file name; otherwise it is listed as unmanaged and left alone.
|
match the file name; otherwise it is listed as unmanaged and left alone.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "schedls"
|
name = "schedls"
|
||||||
version = "0.4.1"
|
version = "0.5.0"
|
||||||
description = "Inspect and manage Linux scheduled jobs"
|
description = "Inspect and manage Linux scheduled jobs"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Marco D'Aleo", email = "marco@marcodaleo.com" }
|
{ name = "Marco D'Aleo", email = "marco@marcodaleo.com" }
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ class SchedulerBackend:
|
|||||||
def discover(self, scopes: Sequence[Scope]) -> list[ScheduledJob]:
|
def discover(self, scopes: Sequence[Scope]) -> list[ScheduledJob]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def discovery_warnings(self) -> Sequence[str]:
|
||||||
|
return ()
|
||||||
|
|
||||||
def find(self, name: str) -> ScheduledJob | None:
|
def find(self, name: str) -> ScheduledJob | None:
|
||||||
for job in self.discover([Scope.USER, Scope.SYSTEM]):
|
for job in self.discover([Scope.USER, Scope.SYSTEM]):
|
||||||
if job.name == name:
|
if job.name == name:
|
||||||
|
|||||||
+234
-26
@@ -92,6 +92,9 @@ class CrontabEntry:
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
expression: str | None = None
|
expression: str | None = None
|
||||||
command: str | None = None
|
command: str | None = None
|
||||||
|
raw_command: str | None = None
|
||||||
|
environment: tuple[tuple[str, str], ...] = ()
|
||||||
|
environment_malformed: bool = False
|
||||||
user: str | None = None
|
user: str | None = None
|
||||||
marker_name: str | None = None
|
marker_name: str | None = None
|
||||||
extra: dict[str, str] = field(default_factory=dict)
|
extra: dict[str, str] = field(default_factory=dict)
|
||||||
@@ -181,7 +184,13 @@ class CrontabDocument:
|
|||||||
open_begin = None
|
open_begin = None
|
||||||
open_lines = []
|
open_lines = []
|
||||||
elif open_begin is not None:
|
elif open_begin is not None:
|
||||||
if entry.kind in {"job", "comment", "env", "blank"}:
|
if entry.kind == "disabled_malformed":
|
||||||
|
self.warnings.append(f"line {number}: malformed schedls disabled job")
|
||||||
|
open_lines.append(entry)
|
||||||
|
elif entry.kind in {"job", "disabled_job"} and entry.environment_malformed:
|
||||||
|
self.warnings.append(f"line {number}: malformed schedls environment prefix")
|
||||||
|
open_lines.append(entry)
|
||||||
|
elif entry.kind in {"job", "disabled_job", "comment", "env", "blank"}:
|
||||||
open_lines.append(entry)
|
open_lines.append(entry)
|
||||||
if open_begin is not None:
|
if open_begin is not None:
|
||||||
self.warnings.append(
|
self.warnings.append(
|
||||||
@@ -192,6 +201,12 @@ class CrontabDocument:
|
|||||||
stripped = raw.strip()
|
stripped = raw.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
return CrontabEntry(number, raw, "blank")
|
return CrontabEntry(number, raw, "blank")
|
||||||
|
disabled_marker = renderer.DISABLED_PREFIX.rstrip()
|
||||||
|
if stripped == disabled_marker or stripped.startswith(renderer.DISABLED_PREFIX):
|
||||||
|
if not raw.startswith(renderer.DISABLED_PREFIX):
|
||||||
|
return CrontabEntry(number, raw, "disabled_malformed")
|
||||||
|
entry = self._job_entry(number, raw, raw[len(renderer.DISABLED_PREFIX) :].strip(), "disabled_job")
|
||||||
|
return entry or CrontabEntry(number, raw, "disabled_malformed")
|
||||||
if stripped.startswith("#"):
|
if stripped.startswith("#"):
|
||||||
match = _MARKER_RE.match(stripped)
|
match = _MARKER_RE.match(stripped)
|
||||||
if match:
|
if match:
|
||||||
@@ -202,10 +217,24 @@ class CrontabDocument:
|
|||||||
return CrontabEntry(number, raw, "comment")
|
return CrontabEntry(number, raw, "comment")
|
||||||
if _ENV_RE.match(stripped):
|
if _ENV_RE.match(stripped):
|
||||||
return CrontabEntry(number, raw, "env")
|
return CrontabEntry(number, raw, "env")
|
||||||
expression, command, user = _split_job(stripped, system=self.system)
|
return self._job_entry(number, raw, stripped, "job") or CrontabEntry(number, raw, "comment")
|
||||||
if expression is None:
|
|
||||||
return CrontabEntry(number, raw, "comment")
|
def _job_entry(self, number: int, raw: str, text: str, kind: str) -> CrontabEntry | None:
|
||||||
return CrontabEntry(number, raw, "job", expression=expression, command=command, user=user)
|
expression, command, user = _split_job(text, system=self.system)
|
||||||
|
if expression is None or command is None:
|
||||||
|
return None
|
||||||
|
parsed = renderer.parse_environment_prefix(command)
|
||||||
|
return CrontabEntry(
|
||||||
|
number,
|
||||||
|
raw,
|
||||||
|
kind,
|
||||||
|
expression=expression,
|
||||||
|
command=parsed.command,
|
||||||
|
raw_command=command,
|
||||||
|
environment=parsed.environment,
|
||||||
|
environment_malformed=parsed.malformed,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
# -- queries --------------------------------------------------------------
|
# -- queries --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -258,7 +287,7 @@ class CrontabDocument:
|
|||||||
block = self._blocks.get(name)
|
block = self._blocks.get(name)
|
||||||
if block is None:
|
if block is None:
|
||||||
raise SafetyRefusalError(f"no schedls-managed cron block named {name!r}.")
|
raise SafetyRefusalError(f"no schedls-managed cron block named {name!r}.")
|
||||||
job_lines = [entry for entry in block.job_lines if entry.kind == "job"]
|
job_lines = [entry for entry in block.job_lines if entry.kind in {"job", "disabled_job"}]
|
||||||
if len(job_lines) != 1:
|
if len(job_lines) != 1:
|
||||||
raise SafetyRefusalError(
|
raise SafetyRefusalError(
|
||||||
f"schedls-managed cron block {name!r} must contain exactly one job; refusing to edit."
|
f"schedls-managed cron block {name!r} must contain exactly one job; refusing to edit."
|
||||||
@@ -269,6 +298,20 @@ class CrontabDocument:
|
|||||||
job = self.managed_job_line(name)
|
job = self.managed_job_line(name)
|
||||||
if has_unsafe_control_characters(line):
|
if has_unsafe_control_characters(line):
|
||||||
raise SafetyRefusalError("cron job line must not contain NUL or newline")
|
raise SafetyRefusalError("cron job line must not contain NUL or newline")
|
||||||
|
if job.kind == "disabled_job":
|
||||||
|
line = renderer.render_disabled_line(line)
|
||||||
|
lines = list(self.lines)
|
||||||
|
lines[job.index - 1] = line
|
||||||
|
body = "\n".join(lines)
|
||||||
|
if self.had_trailing_newline and body:
|
||||||
|
return body + "\n"
|
||||||
|
return body
|
||||||
|
|
||||||
|
def with_toggled_enabled(self, name: str, enabled: bool) -> str:
|
||||||
|
job = self.managed_job_line(name)
|
||||||
|
if (enabled and job.kind == "job") or (not enabled and job.kind == "disabled_job"):
|
||||||
|
return self.text
|
||||||
|
line = renderer.render_enabled_line(job.raw) if enabled else renderer.render_disabled_line(job.raw)
|
||||||
lines = list(self.lines)
|
lines = list(self.lines)
|
||||||
lines[job.index - 1] = line
|
lines[job.index - 1] = line
|
||||||
body = "\n".join(lines)
|
body = "\n".join(lines)
|
||||||
@@ -306,6 +349,8 @@ class CronBackend(SchedulerBackend):
|
|||||||
create=True,
|
create=True,
|
||||||
update=True,
|
update=True,
|
||||||
remove=True,
|
remove=True,
|
||||||
|
enable=True,
|
||||||
|
disable=True,
|
||||||
next_run=False,
|
next_run=False,
|
||||||
validation=True,
|
validation=True,
|
||||||
)
|
)
|
||||||
@@ -314,6 +359,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
self.runner = runner
|
self.runner = runner
|
||||||
self.paths = paths or CronPaths()
|
self.paths = paths or CronPaths()
|
||||||
self._capabilities: CronCapabilities | None = None
|
self._capabilities: CronCapabilities | None = None
|
||||||
|
self._discovery_warnings: list[str] = []
|
||||||
|
|
||||||
def crontab_available(self) -> bool:
|
def crontab_available(self) -> bool:
|
||||||
return self.runner.has("crontab")
|
return self.runner.has("crontab")
|
||||||
@@ -381,6 +427,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
# -- discovery ------------------------------------------------------------
|
# -- discovery ------------------------------------------------------------
|
||||||
|
|
||||||
def discover(self, scopes: Sequence[Scope]) -> list[ScheduledJob]:
|
def discover(self, scopes: Sequence[Scope]) -> list[ScheduledJob]:
|
||||||
|
self._discovery_warnings = []
|
||||||
jobs: list[ScheduledJob] = []
|
jobs: list[ScheduledJob] = []
|
||||||
if Scope.USER in scopes:
|
if Scope.USER in scopes:
|
||||||
jobs.extend(self._discover_user_crontab())
|
jobs.extend(self._discover_user_crontab())
|
||||||
@@ -390,21 +437,25 @@ class CronBackend(SchedulerBackend):
|
|||||||
jobs.extend(self._discover_periodic_directories())
|
jobs.extend(self._discover_periodic_directories())
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
def discovery_warnings(self) -> Sequence[str]:
|
||||||
|
return tuple(self._discovery_warnings)
|
||||||
|
|
||||||
def _discover_user_crontab(self) -> list[ScheduledJob]:
|
def _discover_user_crontab(self) -> list[ScheduledJob]:
|
||||||
if not self.crontab_available():
|
if not self.crontab_available():
|
||||||
return []
|
return []
|
||||||
document = self.read()
|
document = self.read()
|
||||||
|
self._record_document_warnings("current user's crontab", document)
|
||||||
jobs: list[ScheduledJob] = []
|
jobs: list[ScheduledJob] = []
|
||||||
managed_indices: set[int] = set()
|
managed_indices: set[int] = set()
|
||||||
for name, block in document.managed_blocks().items():
|
for name, block in document.managed_blocks().items():
|
||||||
managed_indices.update(range(block.begin_index, block.end_index + 1))
|
managed_indices.update(range(block.begin_index, block.end_index + 1))
|
||||||
job_line = next((line for line in block.job_lines if line.kind == "job"), None)
|
job_line = next((line for line in block.job_lines if line.kind in {"job", "disabled_job"}), None)
|
||||||
job = ScheduledJob(
|
job = ScheduledJob(
|
||||||
name=name,
|
name=name,
|
||||||
backend=Backend.CRON,
|
backend=Backend.CRON,
|
||||||
scope=Scope.USER,
|
scope=Scope.USER,
|
||||||
managed=True,
|
managed=True,
|
||||||
enabled=None,
|
enabled=job_line.kind == "job" if job_line else None,
|
||||||
schedule=Schedule(ScheduleKind.CRON, (job_line.expression or "") if job_line else ""),
|
schedule=Schedule(ScheduleKind.CRON, (job_line.expression or "") if job_line else ""),
|
||||||
command=Command(argv=(), raw=job_line.command if job_line else ""),
|
command=Command(argv=(), raw=job_line.command if job_line else ""),
|
||||||
source=JobSource("current user's crontab", line=block.begin_index),
|
source=JobSource("current user's crontab", line=block.begin_index),
|
||||||
@@ -412,6 +463,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
cron=CronDetails(
|
cron=CronDetails(
|
||||||
expression=job_line.expression if job_line else None,
|
expression=job_line.expression if job_line else None,
|
||||||
line=block.begin_index,
|
line=block.begin_index,
|
||||||
|
environment=job_line.environment if job_line else (),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
jobs.append(job)
|
jobs.append(job)
|
||||||
@@ -426,7 +478,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
managed=False,
|
managed=False,
|
||||||
enabled=None,
|
enabled=None,
|
||||||
schedule=Schedule(ScheduleKind.CRON, entry.expression or ""),
|
schedule=Schedule(ScheduleKind.CRON, entry.expression or ""),
|
||||||
command=Command(argv=(), raw=entry.command or ""),
|
command=Command(argv=(), raw=entry.raw_command or entry.command or ""),
|
||||||
source=JobSource("current user's crontab", line=entry.index),
|
source=JobSource("current user's crontab", line=entry.index),
|
||||||
cron=CronDetails(
|
cron=CronDetails(
|
||||||
expression=entry.expression,
|
expression=entry.expression,
|
||||||
@@ -452,6 +504,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
if text is None:
|
if text is None:
|
||||||
return []
|
return []
|
||||||
document = CrontabDocument(text, system=True)
|
document = CrontabDocument(text, system=True)
|
||||||
|
self._record_document_warnings(path, document)
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
blocks = document.managed_blocks()
|
blocks = document.managed_blocks()
|
||||||
managed_name: str | None = None
|
managed_name: str | None = None
|
||||||
@@ -476,14 +529,14 @@ class CronBackend(SchedulerBackend):
|
|||||||
if managed_name is not None:
|
if managed_name is not None:
|
||||||
block = blocks[managed_name]
|
block = blocks[managed_name]
|
||||||
managed_range = set(range(block.begin_index, block.end_index + 1))
|
managed_range = set(range(block.begin_index, block.end_index + 1))
|
||||||
job_line = next((line for line in block.job_lines if line.kind == "job"), None)
|
job_line = next((line for line in block.job_lines if line.kind in {"job", "disabled_job"}), None)
|
||||||
jobs.append(
|
jobs.append(
|
||||||
ScheduledJob(
|
ScheduledJob(
|
||||||
name=managed_name,
|
name=managed_name,
|
||||||
backend=Backend.CRON,
|
backend=Backend.CRON,
|
||||||
scope=Scope.SYSTEM,
|
scope=Scope.SYSTEM,
|
||||||
managed=True,
|
managed=True,
|
||||||
enabled=None,
|
enabled=job_line.kind == "job" if job_line else None,
|
||||||
schedule=Schedule(ScheduleKind.CRON, (job_line.expression or "") if job_line else ""),
|
schedule=Schedule(ScheduleKind.CRON, (job_line.expression or "") if job_line else ""),
|
||||||
command=Command(argv=(), raw=job_line.command if job_line else ""),
|
command=Command(argv=(), raw=job_line.command if job_line else ""),
|
||||||
source=JobSource("system cron drop-in", path=path, line=block.begin_index),
|
source=JobSource("system cron drop-in", path=path, line=block.begin_index),
|
||||||
@@ -491,6 +544,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
expression=job_line.expression if job_line else None,
|
expression=job_line.expression if job_line else None,
|
||||||
raw_line=job_line.raw if job_line else None,
|
raw_line=job_line.raw if job_line else None,
|
||||||
line=block.begin_index,
|
line=block.begin_index,
|
||||||
|
environment=job_line.environment if job_line else (),
|
||||||
user=job_line.user if job_line else None,
|
user=job_line.user if job_line else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -506,7 +560,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
managed=False,
|
managed=False,
|
||||||
enabled=None,
|
enabled=None,
|
||||||
schedule=Schedule(ScheduleKind.CRON, entry.expression or ""),
|
schedule=Schedule(ScheduleKind.CRON, entry.expression or ""),
|
||||||
command=Command(argv=(), raw=entry.command or ""),
|
command=Command(argv=(), raw=entry.raw_command or entry.command or ""),
|
||||||
source=JobSource("system cron", path=path, line=entry.index),
|
source=JobSource("system cron", path=path, line=entry.index),
|
||||||
cron=CronDetails(
|
cron=CronDetails(
|
||||||
expression=entry.expression,
|
expression=entry.expression,
|
||||||
@@ -517,6 +571,8 @@ class CronBackend(SchedulerBackend):
|
|||||||
warnings=tuple(warnings),
|
warnings=tuple(warnings),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if warnings and not jobs:
|
||||||
|
self._discovery_warnings.extend(warnings)
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
def _discover_periodic_directories(self) -> list[ScheduledJob]:
|
def _discover_periodic_directories(self) -> list[ScheduledJob]:
|
||||||
@@ -576,7 +632,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
managed=False,
|
managed=False,
|
||||||
enabled=None,
|
enabled=None,
|
||||||
schedule=Schedule(ScheduleKind.CRON, entry.expression or ""),
|
schedule=Schedule(ScheduleKind.CRON, entry.expression or ""),
|
||||||
command=Command(argv=(), raw=entry.command or ""),
|
command=Command(argv=(), raw=entry.raw_command or entry.command or ""),
|
||||||
source=JobSource(f"user crontab ({name})", path=path, line=entry.index),
|
source=JobSource(f"user crontab ({name})", path=path, line=entry.index),
|
||||||
cron=CronDetails(
|
cron=CronDetails(
|
||||||
expression=entry.expression,
|
expression=entry.expression,
|
||||||
@@ -602,6 +658,9 @@ class CronBackend(SchedulerBackend):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def _record_document_warnings(self, source: str, document: CrontabDocument) -> None:
|
||||||
|
self._discovery_warnings.extend(f"{source}: {warning}" for warning in document.warnings)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _list_dir(directory: str) -> list[str]:
|
def _list_dir(directory: str) -> list[str]:
|
||||||
try:
|
try:
|
||||||
@@ -620,7 +679,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
if not self.crontab_available():
|
if not self.crontab_available():
|
||||||
raise OperationalError("crontab is not available")
|
raise OperationalError("crontab is not available")
|
||||||
expression = renderer.validate_expression(spec.cron_expression or "")
|
expression = renderer.validate_expression(spec.cron_expression or "")
|
||||||
line = renderer.render_line(expression, spec.command)
|
line = renderer.render_line(expression, spec.command, environment=spec.environment)
|
||||||
document = self.read()
|
document = self.read()
|
||||||
new_text = document.with_block(spec.name, [line])
|
new_text = document.with_block(spec.name, [line])
|
||||||
plan = Plan(backend=self.name, action="create")
|
plan = Plan(backend=self.name, action="create")
|
||||||
@@ -655,7 +714,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
check_trusted_directory(directory, expected_uid=0)
|
check_trusted_directory(directory, expected_uid=0)
|
||||||
expression = renderer.validate_expression(spec.cron_expression or "")
|
expression = renderer.validate_expression(spec.cron_expression or "")
|
||||||
run_as = renderer.validate_run_as(spec.run_as or "root")
|
run_as = renderer.validate_run_as(spec.run_as or "root")
|
||||||
line = renderer.render_system_line(expression, spec.command, run_as)
|
line = renderer.render_system_line(expression, spec.command, run_as, environment=spec.environment)
|
||||||
content = renderer.render_system_file(spec.name, [line])
|
content = renderer.render_system_file(spec.name, [line])
|
||||||
path = os.path.join(directory, f"{_MANAGED_PREFIX}{spec.name}")
|
path = os.path.join(directory, f"{_MANAGED_PREFIX}{spec.name}")
|
||||||
if os.path.lexists(path):
|
if os.path.lexists(path):
|
||||||
@@ -705,13 +764,18 @@ class CronBackend(SchedulerBackend):
|
|||||||
document = self.read()
|
document = self.read()
|
||||||
old_line = document.managed_job_line(current.name)
|
old_line = document.managed_job_line(current.name)
|
||||||
expression = renderer.validate_expression(spec.cron_expression or "")
|
expression = renderer.validate_expression(spec.cron_expression or "")
|
||||||
|
environment = spec.environment if spec.replace_environment else old_line.environment
|
||||||
line = self._render_updated_line(
|
line = self._render_updated_line(
|
||||||
expression,
|
expression,
|
||||||
spec.command,
|
spec.command,
|
||||||
|
old_line.raw_command,
|
||||||
old_line.command,
|
old_line.command,
|
||||||
preserve_command=spec.command == current.command,
|
preserve_command=spec.command == current.command,
|
||||||
|
existing_environment=old_line.environment,
|
||||||
|
environment=environment,
|
||||||
)
|
)
|
||||||
new_text = document.with_updated_job(current.name, line)
|
new_text = document.with_updated_job(current.name, line)
|
||||||
|
expected_line = CrontabDocument(new_text).managed_job_line(current.name).raw
|
||||||
plan = Plan(backend=self.name, action="update")
|
plan = Plan(backend=self.name, action="update")
|
||||||
plan.summary = [
|
plan.summary = [
|
||||||
("Backend", "cron (current user)"),
|
("Backend", "cron (current user)"),
|
||||||
@@ -727,7 +791,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
"name": current.name,
|
"name": current.name,
|
||||||
"old_text": document.text,
|
"old_text": document.text,
|
||||||
"new_text": new_text,
|
"new_text": new_text,
|
||||||
"expected_line": line,
|
"expected_line": expected_line,
|
||||||
}
|
}
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
@@ -753,14 +817,19 @@ class CronBackend(SchedulerBackend):
|
|||||||
old_line = document.managed_job_line(current.name)
|
old_line = document.managed_job_line(current.name)
|
||||||
run_as = renderer.validate_run_as(old_line.user or "")
|
run_as = renderer.validate_run_as(old_line.user or "")
|
||||||
expression = renderer.validate_expression(spec.cron_expression or "")
|
expression = renderer.validate_expression(spec.cron_expression or "")
|
||||||
|
environment = spec.environment if spec.replace_environment else old_line.environment
|
||||||
line = self._render_updated_line(
|
line = self._render_updated_line(
|
||||||
expression,
|
expression,
|
||||||
spec.command,
|
spec.command,
|
||||||
|
old_line.raw_command,
|
||||||
old_line.command,
|
old_line.command,
|
||||||
run_as=run_as,
|
run_as=run_as,
|
||||||
preserve_command=spec.command == current.command,
|
preserve_command=spec.command == current.command,
|
||||||
|
existing_environment=old_line.environment,
|
||||||
|
environment=environment,
|
||||||
)
|
)
|
||||||
new_text = document.with_updated_job(current.name, line)
|
new_text = document.with_updated_job(current.name, line)
|
||||||
|
expected_line = CrontabDocument(new_text, system=True).managed_job_line(current.name).raw
|
||||||
plan = Plan(backend=self.name, action="update")
|
plan = Plan(backend=self.name, action="update")
|
||||||
plan.summary = [
|
plan.summary = [
|
||||||
("Backend", "cron (system)"),
|
("Backend", "cron (system)"),
|
||||||
@@ -778,7 +847,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
"path": path,
|
"path": path,
|
||||||
"expected_uid": 0,
|
"expected_uid": 0,
|
||||||
"snapshots": {path: content},
|
"snapshots": {path: content},
|
||||||
"expected_line": line,
|
"expected_line": expected_line,
|
||||||
}
|
}
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
@@ -786,20 +855,137 @@ class CronBackend(SchedulerBackend):
|
|||||||
def _render_updated_line(
|
def _render_updated_line(
|
||||||
expression: str,
|
expression: str,
|
||||||
command: Command,
|
command: Command,
|
||||||
|
existing_command_field: str | None,
|
||||||
existing_command: str | None,
|
existing_command: str | None,
|
||||||
*,
|
*,
|
||||||
preserve_command: bool,
|
preserve_command: bool,
|
||||||
|
existing_environment: tuple[tuple[str, str], ...],
|
||||||
|
environment: tuple[tuple[str, str], ...],
|
||||||
run_as: str | None = None,
|
run_as: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if preserve_command:
|
if preserve_command and environment == existing_environment:
|
||||||
if not existing_command or has_unsafe_control_characters(existing_command):
|
if not existing_command_field or has_unsafe_control_characters(existing_command_field):
|
||||||
raise SafetyRefusalError("existing cron command cannot be safely preserved.")
|
raise SafetyRefusalError("existing cron command cannot be safely preserved.")
|
||||||
if run_as is None:
|
if run_as is None:
|
||||||
return f"{expression} {existing_command}"
|
return f"{expression} {existing_command_field}"
|
||||||
return f"{expression} {run_as} {existing_command}"
|
return f"{expression} {run_as} {existing_command_field}"
|
||||||
|
if preserve_command:
|
||||||
|
return renderer.render_line_from_raw_command(
|
||||||
|
expression,
|
||||||
|
existing_command or "",
|
||||||
|
environment=environment,
|
||||||
|
user=run_as,
|
||||||
|
)
|
||||||
if run_as is None:
|
if run_as is None:
|
||||||
return renderer.render_line(expression, command)
|
return renderer.render_line(expression, command, environment=environment)
|
||||||
return renderer.render_system_line(expression, command, run_as)
|
return renderer.render_system_line(expression, command, run_as, environment=environment)
|
||||||
|
|
||||||
|
def plan_set_enabled(self, job: ScheduledJob, enabled: bool) -> Plan:
|
||||||
|
if not job.managed:
|
||||||
|
raise SafetyRefusalError(
|
||||||
|
f"{job.name} was not created by schedls.",
|
||||||
|
hint="schedls will not modify unmanaged schedules by default.",
|
||||||
|
)
|
||||||
|
if job.backend is not Backend.CRON:
|
||||||
|
raise SafetyRefusalError(f"{job.name} is not a cron job.")
|
||||||
|
if job.scope is Scope.USER:
|
||||||
|
return self._plan_set_enabled_user(job, enabled)
|
||||||
|
if job.scope is Scope.SYSTEM:
|
||||||
|
return self._plan_set_enabled_system(job, enabled)
|
||||||
|
raise SafetyRefusalError("unsupported cron scope.")
|
||||||
|
|
||||||
|
def _noop_set_enabled_plan(
|
||||||
|
self,
|
||||||
|
job: ScheduledJob,
|
||||||
|
enabled: bool,
|
||||||
|
*,
|
||||||
|
mode: str,
|
||||||
|
old_text: str | None = None,
|
||||||
|
snapshots: dict[str, str | None] | None = None,
|
||||||
|
) -> Plan:
|
||||||
|
state = "enabled" if enabled else "disabled"
|
||||||
|
plan = Plan(backend=self.name, action="enable" if enabled else "disable")
|
||||||
|
plan.summary = [("Backend", f"cron ({job.scope.value})"), ("State", f"already {state}")]
|
||||||
|
plan.payload = {
|
||||||
|
"mode": "noop",
|
||||||
|
"noop_mode": mode,
|
||||||
|
"action": plan.action,
|
||||||
|
"name": job.name,
|
||||||
|
"old_text": old_text,
|
||||||
|
"snapshots": snapshots or {},
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def _plan_set_enabled_user(self, job: ScheduledJob, enabled: bool) -> Plan:
|
||||||
|
if not self.crontab_available():
|
||||||
|
raise OperationalError("crontab is not available")
|
||||||
|
document = self.read()
|
||||||
|
if (document.managed_job_line(job.name).kind == "job") is enabled:
|
||||||
|
return self._noop_set_enabled_plan(job, enabled, mode="crontab", old_text=document.text)
|
||||||
|
new_text = document.with_toggled_enabled(job.name, enabled)
|
||||||
|
expected_line = CrontabDocument(new_text).managed_job_line(job.name).raw
|
||||||
|
action = "enable" if enabled else "disable"
|
||||||
|
plan = Plan(backend=self.name, action=action)
|
||||||
|
plan.summary = [
|
||||||
|
("Backend", "cron (current user)"),
|
||||||
|
("State", "enabled" if enabled else "disabled"),
|
||||||
|
]
|
||||||
|
plan.commands = self._install_commands(new_text)
|
||||||
|
plan.payload = {
|
||||||
|
"mode": "crontab",
|
||||||
|
"action": action,
|
||||||
|
"name": job.name,
|
||||||
|
"old_text": document.text,
|
||||||
|
"new_text": new_text,
|
||||||
|
"expected_line": expected_line,
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def _plan_set_enabled_system(self, job: ScheduledJob, enabled: bool) -> Plan:
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
raise SafetyRefusalError(
|
||||||
|
"changing a system cron job requires appropriate privileges.",
|
||||||
|
hint=(
|
||||||
|
"Run the command under sudo yourself if that is your intention:\n"
|
||||||
|
" sudo schedls enable NAME\n"
|
||||||
|
" sudo schedls disable NAME"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
renderer.validate_system_name(job.name)
|
||||||
|
directory = self.paths.cron_d
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
raise OperationalError(f"system cron directory does not exist: {directory}")
|
||||||
|
check_trusted_directory(directory, expected_uid=0)
|
||||||
|
path = job.source.path
|
||||||
|
expected_path = os.path.join(directory, f"{_MANAGED_PREFIX}{job.name}")
|
||||||
|
if path != expected_path:
|
||||||
|
raise SafetyRefusalError(f"refusing to edit unexpected system cron file: {path!r}")
|
||||||
|
content = _read_text(path)
|
||||||
|
if content is None:
|
||||||
|
raise OperationalError(f"could not read {path}")
|
||||||
|
document = CrontabDocument(content, system=True)
|
||||||
|
if (document.managed_job_line(job.name).kind == "job") is enabled:
|
||||||
|
return self._noop_set_enabled_plan(job, enabled, mode="file", snapshots={path: content})
|
||||||
|
new_text = document.with_toggled_enabled(job.name, enabled)
|
||||||
|
expected_line = CrontabDocument(new_text, system=True).managed_job_line(job.name).raw
|
||||||
|
action = "enable" if enabled else "disable"
|
||||||
|
plan = Plan(backend=self.name, action=action)
|
||||||
|
plan.summary = [
|
||||||
|
("Backend", "cron (system)"),
|
||||||
|
("State", "enabled" if enabled else "disabled"),
|
||||||
|
("File", path),
|
||||||
|
]
|
||||||
|
plan.files = [FileChange(path=path, content=new_text, mode=0o644, expected_uid=0)]
|
||||||
|
plan.payload = {
|
||||||
|
"mode": "file",
|
||||||
|
"action": action,
|
||||||
|
"name": job.name,
|
||||||
|
"path": path,
|
||||||
|
"expected_uid": 0,
|
||||||
|
"snapshots": {path: content},
|
||||||
|
"expected_line": expected_line,
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
|
||||||
def plan_remove(self, job: ScheduledJob) -> Plan:
|
def plan_remove(self, job: ScheduledJob) -> Plan:
|
||||||
if not job.managed:
|
if not job.managed:
|
||||||
@@ -864,10 +1050,24 @@ class CronBackend(SchedulerBackend):
|
|||||||
# -- applying -------------------------------------------------------------
|
# -- applying -------------------------------------------------------------
|
||||||
|
|
||||||
def apply(self, plan: Plan) -> MutationResult:
|
def apply(self, plan: Plan) -> MutationResult:
|
||||||
|
if plan.payload.get("mode") == "noop":
|
||||||
|
return self._apply_noop(plan)
|
||||||
if plan.payload.get("mode") == "file":
|
if plan.payload.get("mode") == "file":
|
||||||
return self._apply_file(plan)
|
return self._apply_file(plan)
|
||||||
return self._apply_crontab(plan)
|
return self._apply_crontab(plan)
|
||||||
|
|
||||||
|
def _apply_noop(self, plan: Plan) -> MutationResult:
|
||||||
|
if plan.payload.get("noop_mode") == "crontab":
|
||||||
|
if self.read().text != plan.payload.get("old_text"):
|
||||||
|
raise SafetyRefusalError("the crontab changed after the plan was prepared; refusing to apply.")
|
||||||
|
elif plan.payload.get("noop_mode") == "file":
|
||||||
|
snapshots: dict[str, str | None] = plan.payload.get("snapshots", {})
|
||||||
|
if any(_read_text(path) != snapshot for path, snapshot in snapshots.items()):
|
||||||
|
raise SafetyRefusalError(
|
||||||
|
"the system cron drop-in changed after the plan was prepared; refusing to apply."
|
||||||
|
)
|
||||||
|
return MutationResult(changed=False)
|
||||||
|
|
||||||
def _apply_crontab(self, plan: Plan) -> MutationResult:
|
def _apply_crontab(self, plan: Plan) -> MutationResult:
|
||||||
old_text = plan.payload.get("old_text", "")
|
old_text = plan.payload.get("old_text", "")
|
||||||
new_text = plan.payload.get("new_text", "")
|
new_text = plan.payload.get("new_text", "")
|
||||||
@@ -925,6 +1125,10 @@ class CronBackend(SchedulerBackend):
|
|||||||
message = "System cron drop-in installed."
|
message = "System cron drop-in installed."
|
||||||
elif action == "update":
|
elif action == "update":
|
||||||
message = "System cron drop-in updated."
|
message = "System cron drop-in updated."
|
||||||
|
elif action == "enable":
|
||||||
|
message = "System cron job enabled."
|
||||||
|
elif action == "disable":
|
||||||
|
message = "System cron job disabled."
|
||||||
elif action == "remove":
|
elif action == "remove":
|
||||||
message = "System cron drop-in removed."
|
message = "System cron drop-in removed."
|
||||||
else:
|
else:
|
||||||
@@ -965,7 +1169,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
action = plan.payload.get("action")
|
action = plan.payload.get("action")
|
||||||
expected_line = plan.payload.get("expected_line")
|
expected_line = plan.payload.get("expected_line")
|
||||||
if plan.payload.get("mode") == "file":
|
if plan.payload.get("mode") == "file":
|
||||||
if action not in {"create", "update"} or not name:
|
if action not in {"create", "update", "enable", "disable"} or not name:
|
||||||
return
|
return
|
||||||
document = CrontabDocument(_read_text(plan.payload.get("path", "")) or "", system=True)
|
document = CrontabDocument(_read_text(plan.payload.get("path", "")) or "", system=True)
|
||||||
try:
|
try:
|
||||||
@@ -976,7 +1180,7 @@ class CronBackend(SchedulerBackend):
|
|||||||
raise OperationalError("the drop-in was written but the schedls block could not be verified.")
|
raise OperationalError("the drop-in was written but the schedls block could not be verified.")
|
||||||
return
|
return
|
||||||
document = self.read()
|
document = self.read()
|
||||||
if action in {"create", "update"} and name:
|
if action in {"create", "update", "enable", "disable"} and name:
|
||||||
try:
|
try:
|
||||||
job_line = document.managed_job_line(name)
|
job_line = document.managed_job_line(name)
|
||||||
except SafetyRefusalError as exc:
|
except SafetyRefusalError as exc:
|
||||||
@@ -993,16 +1197,20 @@ class CronBackend(SchedulerBackend):
|
|||||||
if not isinstance(expected_line, str):
|
if not isinstance(expected_line, str):
|
||||||
return False
|
return False
|
||||||
expected_entries = [
|
expected_entries = [
|
||||||
entry for entry in CrontabDocument(expected_line, system=system).entries if entry.kind == "job"
|
entry
|
||||||
|
for entry in CrontabDocument(expected_line, system=system).entries
|
||||||
|
if entry.kind in {"job", "disabled_job"}
|
||||||
]
|
]
|
||||||
if len(expected_entries) != 1:
|
if len(expected_entries) != 1:
|
||||||
return False
|
return False
|
||||||
expected = expected_entries[0]
|
expected = expected_entries[0]
|
||||||
return (
|
return (
|
||||||
|
job_line.kind,
|
||||||
job_line.expression,
|
job_line.expression,
|
||||||
job_line.user,
|
job_line.user,
|
||||||
job_line.command,
|
job_line.command,
|
||||||
) == (
|
) == (
|
||||||
|
expected.kind,
|
||||||
expected.expression,
|
expected.expression,
|
||||||
expected.user,
|
expected.user,
|
||||||
expected.command,
|
expected.command,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from ..errors import (
|
from ..errors import (
|
||||||
@@ -390,6 +391,8 @@ class SystemdBackend(SchedulerBackend):
|
|||||||
if current.backend is not Backend.SYSTEMD or current.systemd is None:
|
if current.backend is not Backend.SYSTEMD or current.systemd is None:
|
||||||
raise SafetyRefusalError(f"{current.name} is not a systemd timer.")
|
raise SafetyRefusalError(f"{current.name} is not a systemd timer.")
|
||||||
self._require_scope(current.scope)
|
self._require_scope(current.scope)
|
||||||
|
if not spec.replace_environment:
|
||||||
|
spec = replace(spec, environment=current.systemd.environment)
|
||||||
self._validate_spec(spec)
|
self._validate_spec(spec)
|
||||||
service_unit = unit_name(current.name, "service")
|
service_unit = unit_name(current.name, "service")
|
||||||
timer_unit = unit_name(current.name, "timer")
|
timer_unit = unit_name(current.name, "timer")
|
||||||
|
|||||||
+55
-14
@@ -150,7 +150,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def _add_schedule_options(parser: argparse.ArgumentParser, *, creation: bool) -> None:
|
def _add_schedule_options(
|
||||||
|
parser: argparse.ArgumentParser,
|
||||||
|
*,
|
||||||
|
creation: bool,
|
||||||
|
environment_clearable: bool = False,
|
||||||
|
) -> None:
|
||||||
parser.add_argument("--calendar", action="append", metavar="EXPR", help="native OnCalendar expression (repeatable)")
|
parser.add_argument("--calendar", action="append", metavar="EXPR", help="native OnCalendar expression (repeatable)")
|
||||||
parser.add_argument("--daily", metavar="TIME", help="daily at TIME (HH:MM)")
|
parser.add_argument("--daily", metavar="TIME", help="daily at TIME (HH:MM)")
|
||||||
parser.add_argument("--weekdays", metavar="TIME", help="Mon..Fri at TIME")
|
parser.add_argument("--weekdays", metavar="TIME", help="Mon..Fri at TIME")
|
||||||
@@ -161,7 +166,15 @@ def _add_schedule_options(parser: argparse.ArgumentParser, *, creation: bool) ->
|
|||||||
parser.add_argument("--jitter", metavar="DURATION", help="randomized delay, e.g. 5min (systemd timers)")
|
parser.add_argument("--jitter", metavar="DURATION", help="randomized delay, e.g. 5min (systemd timers)")
|
||||||
parser.add_argument("--accuracy", metavar="DURATION", help="timer accuracy window, e.g. 1min (systemd timers)")
|
parser.add_argument("--accuracy", metavar="DURATION", help="timer accuracy window, e.g. 1min (systemd timers)")
|
||||||
parser.add_argument("--working-directory", metavar="PATH", help="run the command from PATH (systemd timers)")
|
parser.add_argument("--working-directory", metavar="PATH", help="run the command from PATH (systemd timers)")
|
||||||
parser.add_argument("--env", action="append", metavar="KEY=VALUE", help="set an environment variable (repeatable)")
|
environment_options = parser.add_mutually_exclusive_group() if environment_clearable else parser
|
||||||
|
environment_options.add_argument(
|
||||||
|
"--env",
|
||||||
|
action="append",
|
||||||
|
metavar="KEY=VALUE",
|
||||||
|
help="set an environment variable (repeatable)",
|
||||||
|
)
|
||||||
|
if environment_clearable:
|
||||||
|
environment_options.add_argument("--clear-env", action="store_true", help="remove all environment variables")
|
||||||
parser.add_argument("--shell", metavar="SCRIPT", help="run SCRIPT through /bin/sh instead of an argv")
|
parser.add_argument("--shell", metavar="SCRIPT", help="run SCRIPT through /bin/sh instead of an argv")
|
||||||
if creation:
|
if creation:
|
||||||
scope = parser.add_mutually_exclusive_group()
|
scope = parser.add_mutually_exclusive_group()
|
||||||
@@ -184,7 +197,7 @@ def _add_creation_options(parser: argparse.ArgumentParser) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _add_edit_options(parser: argparse.ArgumentParser) -> None:
|
def _add_edit_options(parser: argparse.ArgumentParser) -> None:
|
||||||
_add_schedule_options(parser, creation=False)
|
_add_schedule_options(parser, creation=False, environment_clearable=True)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--command",
|
"--command",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -359,7 +372,7 @@ def _cmd_set_enabled(context: Context, args: argparse.Namespace, enabled: bool)
|
|||||||
if not (backend.capabilities.enable if enabled else backend.capabilities.disable):
|
if not (backend.capabilities.enable if enabled else backend.capabilities.disable):
|
||||||
raise UsageError(
|
raise UsageError(
|
||||||
f"{job.backend.value} does not support enable/disable.",
|
f"{job.backend.value} does not support enable/disable.",
|
||||||
hint="Cron has no universal native enabled/disabled concept.",
|
hint="This scheduler backend does not expose an enabled/disabled state for this job.",
|
||||||
)
|
)
|
||||||
plan = backend.plan_set_enabled(job, enabled)
|
plan = backend.plan_set_enabled(job, enabled)
|
||||||
result = mutate_ops.run_plan(
|
result = mutate_ops.run_plan(
|
||||||
@@ -463,6 +476,17 @@ def _parse_environment(entries: list[str] | None) -> tuple[tuple[str, str], ...]
|
|||||||
return tuple(result)
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _environment_for_edit(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
existing: tuple[tuple[str, str], ...],
|
||||||
|
) -> tuple[tuple[str, str], ...]:
|
||||||
|
if args.clear_env:
|
||||||
|
return ()
|
||||||
|
if args.env is not None:
|
||||||
|
return _parse_environment(args.env)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
|
||||||
def _calendar_from_args(args: argparse.Namespace) -> list[str]:
|
def _calendar_from_args(args: argparse.Namespace) -> list[str]:
|
||||||
convenience_exprs: list[str] = []
|
convenience_exprs: list[str] = []
|
||||||
if args.daily:
|
if args.daily:
|
||||||
@@ -526,8 +550,6 @@ def _spec_for_new(args: argparse.Namespace, tail: list[str]) -> JobSpec:
|
|||||||
raise UsageError("--calendar is a systemd option; use --cron-expr for cron.")
|
raise UsageError("--calendar is a systemd option; use --cron-expr for cron.")
|
||||||
if args.jitter or args.accuracy or args.persistent or args.working_directory:
|
if args.jitter or args.accuracy or args.persistent or args.working_directory:
|
||||||
raise UsageError("--jitter/--accuracy/--persistent/--working-directory are systemd options.")
|
raise UsageError("--jitter/--accuracy/--persistent/--working-directory are systemd options.")
|
||||||
if args.env:
|
|
||||||
raise UsageError("--env is a systemd option; cron environment variables are not supported yet.")
|
|
||||||
expression = _cron_expression_from_args(args)
|
expression = _cron_expression_from_args(args)
|
||||||
if not expression:
|
if not expression:
|
||||||
raise UsageError("no schedule given.", hint="Use --cron-expr or a convenience flag such as --daily.")
|
raise UsageError("no schedule given.", hint="Use --cron-expr or a convenience flag such as --daily.")
|
||||||
@@ -539,6 +561,7 @@ def _spec_for_new(args: argparse.Namespace, tail: list[str]) -> JobSpec:
|
|||||||
command=command,
|
command=command,
|
||||||
cron_expression=expression,
|
cron_expression=expression,
|
||||||
run_as=args.run_as or "root",
|
run_as=args.run_as or "root",
|
||||||
|
environment=environment,
|
||||||
)
|
)
|
||||||
if args.run_as:
|
if args.run_as:
|
||||||
raise UsageError("--run-as requires --system for cron jobs.", hint="Cron jobs run as the current user.")
|
raise UsageError("--run-as requires --system for cron jobs.", hint="Cron jobs run as the current user.")
|
||||||
@@ -548,6 +571,7 @@ def _spec_for_new(args: argparse.Namespace, tail: list[str]) -> JobSpec:
|
|||||||
scope=Scope.USER,
|
scope=Scope.USER,
|
||||||
command=command,
|
command=command,
|
||||||
cron_expression=expression,
|
cron_expression=expression,
|
||||||
|
environment=environment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -581,15 +605,14 @@ def _spec_for_edit(job: ScheduledJob, args: argparse.Namespace, tail: list[str])
|
|||||||
working_directory=args.working_directory
|
working_directory=args.working_directory
|
||||||
if args.working_directory is not None
|
if args.working_directory is not None
|
||||||
else (job.systemd.working_directory if job.systemd else None),
|
else (job.systemd.working_directory if job.systemd else None),
|
||||||
environment=_parse_environment(args.env) if args.env else (job.systemd.environment if job.systemd else ()),
|
environment=_environment_for_edit(args, job.systemd.environment if job.systemd else ()),
|
||||||
|
replace_environment=args.clear_env or args.env is not None,
|
||||||
)
|
)
|
||||||
if job.backend is Backend.CRON:
|
if job.backend is Backend.CRON:
|
||||||
if args.calendar:
|
if args.calendar:
|
||||||
raise UsageError("--calendar is a systemd option; use --cron-expr for cron.")
|
raise UsageError("--calendar is a systemd option; use --cron-expr for cron.")
|
||||||
if args.persistent or args.no_persistent or args.jitter or args.accuracy or args.working_directory:
|
if args.persistent or args.no_persistent or args.jitter or args.accuracy or args.working_directory:
|
||||||
raise UsageError("--persistent/--jitter/--accuracy/--working-directory are systemd options.")
|
raise UsageError("--persistent/--jitter/--accuracy/--working-directory are systemd options.")
|
||||||
if args.env:
|
|
||||||
raise UsageError("--env is a systemd option; cron environment variables are not supported yet.")
|
|
||||||
expression = _cron_expression_from_args(args)
|
expression = _cron_expression_from_args(args)
|
||||||
if expression is None:
|
if expression is None:
|
||||||
expression = job.cron.expression if job.cron else job.schedule.expression
|
expression = job.cron.expression if job.cron else job.schedule.expression
|
||||||
@@ -601,6 +624,8 @@ def _spec_for_edit(job: ScheduledJob, args: argparse.Namespace, tail: list[str])
|
|||||||
scope=job.scope,
|
scope=job.scope,
|
||||||
command=command,
|
command=command,
|
||||||
cron_expression=expression,
|
cron_expression=expression,
|
||||||
|
environment=_environment_for_edit(args, job.cron.environment if job.cron else ()),
|
||||||
|
replace_environment=args.clear_env or args.env is not None,
|
||||||
)
|
)
|
||||||
raise UsageError(f"editing {job.backend.value} jobs is not supported yet.")
|
raise UsageError(f"editing {job.backend.value} jobs is not supported yet.")
|
||||||
|
|
||||||
@@ -682,6 +707,7 @@ def _wizard_new(args: argparse.Namespace, tail: list[str], prompter: Prompter) -
|
|||||||
_apply_schedule(args, prompter.schedule(backend))
|
_apply_schedule(args, prompter.schedule(backend))
|
||||||
if backend is Backend.SYSTEMD:
|
if backend is Backend.SYSTEMD:
|
||||||
_wizard_advanced(args, prompter)
|
_wizard_advanced(args, prompter)
|
||||||
|
_wizard_new_environment(args, prompter)
|
||||||
return args, tail
|
return args, tail
|
||||||
|
|
||||||
|
|
||||||
@@ -705,10 +731,13 @@ def _wizard_advanced(args: argparse.Namespace, prompter: Prompter) -> None:
|
|||||||
working_directory = prompter.optional_text("Working directory (absolute path)")
|
working_directory = prompter.optional_text("Working directory (absolute path)")
|
||||||
if working_directory is not None:
|
if working_directory is not None:
|
||||||
args.working_directory = working_directory
|
args.working_directory = working_directory
|
||||||
|
|
||||||
|
|
||||||
|
def _wizard_new_environment(args: argparse.Namespace, prompter: Prompter) -> None:
|
||||||
|
if args.env is not None:
|
||||||
|
return
|
||||||
if prompter.yes_no("Add environment variables?", default=False):
|
if prompter.yes_no("Add environment variables?", default=False):
|
||||||
entries = prompter.environment()
|
args.env = prompter.environment()
|
||||||
if entries:
|
|
||||||
args.env = entries
|
|
||||||
|
|
||||||
|
|
||||||
def _wizard_edit(
|
def _wizard_edit(
|
||||||
@@ -735,6 +764,8 @@ def _wizard_edit(
|
|||||||
_apply_schedule(args, prompter.schedule(job.backend))
|
_apply_schedule(args, prompter.schedule(job.backend))
|
||||||
if job.backend is Backend.SYSTEMD:
|
if job.backend is Backend.SYSTEMD:
|
||||||
_wizard_edit_advanced(job, args, prompter)
|
_wizard_edit_advanced(job, args, prompter)
|
||||||
|
if job.backend in {Backend.SYSTEMD, Backend.CRON}:
|
||||||
|
_wizard_edit_environment(args, prompter)
|
||||||
return args, tail
|
return args, tail
|
||||||
|
|
||||||
|
|
||||||
@@ -760,8 +791,18 @@ def _wizard_edit_advanced(job: ScheduledJob, args: argparse.Namespace, prompter:
|
|||||||
)
|
)
|
||||||
if working_directory is not None:
|
if working_directory is not None:
|
||||||
args.working_directory = working_directory
|
args.working_directory = working_directory
|
||||||
if prompter.yes_no("Replace environment variables?", default=False):
|
|
||||||
args.env = prompter.environment()
|
|
||||||
|
def _wizard_edit_environment(args: argparse.Namespace, prompter: Prompter) -> None:
|
||||||
|
if args.env is not None or args.clear_env:
|
||||||
|
return
|
||||||
|
if not prompter.yes_no("Replace environment variables?", default=False):
|
||||||
|
return
|
||||||
|
entries = prompter.environment()
|
||||||
|
if entries:
|
||||||
|
args.env = entries
|
||||||
|
else:
|
||||||
|
args.clear_env = True
|
||||||
|
|
||||||
|
|
||||||
def _report_created(context: Context, spec: JobSpec) -> None:
|
def _report_created(context: Context, spec: JobSpec) -> None:
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ class JobSpec:
|
|||||||
accuracy: str | None = None
|
accuracy: str | None = None
|
||||||
working_directory: str | None = None
|
working_directory: str | None = None
|
||||||
environment: tuple[tuple[str, str], ...] = field(default_factory=tuple)
|
environment: tuple[tuple[str, str], ...] = field(default_factory=tuple)
|
||||||
|
replace_environment: bool = False
|
||||||
|
|
||||||
def schedule(self) -> Schedule:
|
def schedule(self) -> Schedule:
|
||||||
if self.backend is Backend.SYSTEMD:
|
if self.backend is Backend.SYSTEMD:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def collect(backends: Sequence[SchedulerBackend], scopes: Sequence[Scope]) -> tu
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
jobs.extend(backend.discover(scopes))
|
jobs.extend(backend.discover(scopes))
|
||||||
|
warnings.extend(f"{backend.name} discovery warning: {warning}" for warning in backend.discovery_warnings())
|
||||||
except SchedlsError as exc:
|
except SchedlsError as exc:
|
||||||
warnings.append(f"{backend.name} discovery failed: {exc.message}")
|
warnings.append(f"{backend.name} discovery failed: {exc.message}")
|
||||||
jobs.sort(key=lambda job: (job.name, job.backend.value))
|
jobs.sort(key=lambda job: (job.name, job.backend.value))
|
||||||
@@ -142,6 +143,8 @@ def render_show(output: Output, job: ScheduledJob) -> None:
|
|||||||
details.append(("SHELL", job.cron.shell))
|
details.append(("SHELL", job.cron.shell))
|
||||||
if job.cron.mailto:
|
if job.cron.mailto:
|
||||||
details.append(("MAILTO", job.cron.mailto))
|
details.append(("MAILTO", job.cron.mailto))
|
||||||
|
for key, value in job.cron.environment:
|
||||||
|
details.append(("Environment", f"{key}={value}"))
|
||||||
width = max(len(key) for key, _ in [*core, *details])
|
width = max(len(key) for key, _ in [*core, *details])
|
||||||
output.key_values(core, width=width)
|
output.key_values(core, width=width)
|
||||||
if details:
|
if details:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ..errors import InvalidScheduleError, SafetyRefusalError
|
from ..errors import InvalidScheduleError, SafetyRefusalError
|
||||||
from ..models import Command
|
from ..models import Command
|
||||||
@@ -17,6 +18,7 @@ from ..security import MANAGED_COMMENT, has_unsafe_control_characters
|
|||||||
|
|
||||||
BLOCK_BEGIN = "# schedls:begin name={name}{extra}"
|
BLOCK_BEGIN = "# schedls:begin name={name}{extra}"
|
||||||
BLOCK_END = "# schedls:end name={name}"
|
BLOCK_END = "# schedls:end name={name}"
|
||||||
|
DISABLED_PREFIX = "# schedls:disabled "
|
||||||
|
|
||||||
_MARKER_RE = re.compile(r"^#\s*schedls:(?P<kind>begin|end)\s+(?P<meta>.*)$")
|
_MARKER_RE = re.compile(r"^#\s*schedls:(?P<kind>begin|end)\s+(?P<meta>.*)$")
|
||||||
_FIELD_RE = re.compile(r"^[0-9A-Za-z*,/\-]+$")
|
_FIELD_RE = re.compile(r"^[0-9A-Za-z*,/\-]+$")
|
||||||
@@ -33,6 +35,15 @@ _NICKNAMES = {
|
|||||||
_RANGE_RE = re.compile(r"^[0-9]{1,2}-[0-9]{1,2}$")
|
_RANGE_RE = re.compile(r"^[0-9]{1,2}-[0-9]{1,2}$")
|
||||||
_SYSTEM_NAME_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
|
_SYSTEM_NAME_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
|
||||||
_USER_RE = re.compile(r"\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,31}\Z")
|
_USER_RE = re.compile(r"\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,31}\Z")
|
||||||
|
_ENV_NAME_RE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z")
|
||||||
|
_ENV_ASSIGNMENT_START_RE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*=")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnvironmentPrefix:
|
||||||
|
environment: tuple[tuple[str, str], ...]
|
||||||
|
command: str
|
||||||
|
malformed: bool = False
|
||||||
|
|
||||||
|
|
||||||
def escape_percent(text: str) -> str:
|
def escape_percent(text: str) -> str:
|
||||||
@@ -46,19 +57,74 @@ def escape_percent(text: str) -> str:
|
|||||||
return text.replace("%", "\\%")
|
return text.replace("%", "\\%")
|
||||||
|
|
||||||
|
|
||||||
def render_command(command: Command) -> str:
|
def unescape_percent(text: str) -> str:
|
||||||
|
"""Model cron's conversion of escaped percent characters before shell parsing."""
|
||||||
|
return text.replace("\\%", "%")
|
||||||
|
|
||||||
|
|
||||||
|
def render_environment(environment: tuple[tuple[str, str], ...]) -> str:
|
||||||
|
assignments: list[str] = []
|
||||||
|
for key, value in environment:
|
||||||
|
if not _ENV_NAME_RE.match(key):
|
||||||
|
raise InvalidScheduleError(f"invalid environment variable name: {key!r}")
|
||||||
|
if has_unsafe_control_characters(value):
|
||||||
|
raise SafetyRefusalError("cron environment values must not contain NUL or newline")
|
||||||
|
assignments.append(f"{key}={shlex.quote(value)}")
|
||||||
|
return " ".join(assignments)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_environment_prefix(command: str) -> EnvironmentPrefix:
|
||||||
|
"""Parse schedls's canonical leading shell assignments without executing them."""
|
||||||
|
lexer = shlex.shlex(unescape_percent(command), posix=True)
|
||||||
|
lexer.whitespace_split = True
|
||||||
|
lexer.commenters = ""
|
||||||
|
environment: list[tuple[str, str]] = []
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
token = lexer.get_token()
|
||||||
|
if token is None or token == lexer.eof:
|
||||||
|
break
|
||||||
|
key, separator, value = token.partition("=")
|
||||||
|
if not separator or not _ENV_NAME_RE.match(key):
|
||||||
|
break
|
||||||
|
environment.append((key, value))
|
||||||
|
except ValueError:
|
||||||
|
if environment or _ENV_ASSIGNMENT_START_RE.match(command):
|
||||||
|
return EnvironmentPrefix((), command, malformed=True)
|
||||||
|
return EnvironmentPrefix((), command)
|
||||||
|
|
||||||
|
if not environment:
|
||||||
|
return EnvironmentPrefix((), command)
|
||||||
|
try:
|
||||||
|
rendered_prefix = escape_percent(render_environment(tuple(environment)))
|
||||||
|
except (InvalidScheduleError, SafetyRefusalError):
|
||||||
|
return EnvironmentPrefix((), command, malformed=True)
|
||||||
|
if not command.startswith(rendered_prefix):
|
||||||
|
return EnvironmentPrefix((), command, malformed=True)
|
||||||
|
remainder = command[len(rendered_prefix) :]
|
||||||
|
if not remainder.startswith(" ") or not remainder[1:]:
|
||||||
|
return EnvironmentPrefix((), command, malformed=True)
|
||||||
|
return EnvironmentPrefix(tuple(environment), remainder[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _render_command_text(command: Command) -> str:
|
||||||
if command.shell:
|
if command.shell:
|
||||||
raw = command.raw or ""
|
raw = command.raw or ""
|
||||||
if has_unsafe_control_characters(raw):
|
if has_unsafe_control_characters(raw):
|
||||||
raise SafetyRefusalError("shell command must not contain NUL or newline")
|
raise SafetyRefusalError("shell command must not contain NUL or newline")
|
||||||
return escape_percent(raw)
|
return raw
|
||||||
if not command.argv:
|
if not command.argv:
|
||||||
raise InvalidScheduleError("no command given")
|
raise InvalidScheduleError("no command given")
|
||||||
for arg in command.argv:
|
for arg in command.argv:
|
||||||
if has_unsafe_control_characters(arg):
|
if has_unsafe_control_characters(arg):
|
||||||
raise SafetyRefusalError("cron command arguments must not contain NUL or newline")
|
raise SafetyRefusalError("cron command arguments must not contain NUL or newline")
|
||||||
quoted = " ".join(shlex.quote(arg) for arg in command.argv)
|
return " ".join(shlex.quote(arg) for arg in command.argv)
|
||||||
return escape_percent(quoted)
|
|
||||||
|
|
||||||
|
def render_command(command: Command, *, environment: tuple[tuple[str, str], ...] = ()) -> str:
|
||||||
|
command_text = _render_command_text(command)
|
||||||
|
assignments = render_environment(environment)
|
||||||
|
return escape_percent(f"{assignments} {command_text}" if assignments else command_text)
|
||||||
|
|
||||||
|
|
||||||
def _validate_field(field: str, *, low: int, high: int, names: bool) -> None:
|
def _validate_field(field: str, *, low: int, high: int, names: bool) -> None:
|
||||||
@@ -120,8 +186,51 @@ def validate_expression(expression: str) -> str:
|
|||||||
return " ".join(fields)
|
return " ".join(fields)
|
||||||
|
|
||||||
|
|
||||||
def render_line(expression: str, command: Command) -> str:
|
def render_line(
|
||||||
return f"{validate_expression(expression)} {render_command(command)}"
|
expression: str,
|
||||||
|
command: Command,
|
||||||
|
*,
|
||||||
|
environment: tuple[tuple[str, str], ...] = (),
|
||||||
|
) -> str:
|
||||||
|
return f"{validate_expression(expression)} {render_command(command, environment=environment)}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_line_from_raw_command(
|
||||||
|
expression: str,
|
||||||
|
raw_command: str,
|
||||||
|
*,
|
||||||
|
environment: tuple[tuple[str, str], ...] = (),
|
||||||
|
user: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render an updated line while preserving an existing cron command field.
|
||||||
|
|
||||||
|
``raw_command`` is already cron-escaped and is therefore appended unchanged.
|
||||||
|
"""
|
||||||
|
if not raw_command or has_unsafe_control_characters(raw_command):
|
||||||
|
raise SafetyRefusalError("existing cron command cannot be safely preserved.")
|
||||||
|
command_field = raw_command
|
||||||
|
assignments = render_environment(environment)
|
||||||
|
if assignments:
|
||||||
|
command_field = f"{escape_percent(assignments)} {raw_command}"
|
||||||
|
fields = [validate_expression(expression)]
|
||||||
|
if user is not None:
|
||||||
|
fields.append(validate_run_as(user))
|
||||||
|
fields.append(command_field)
|
||||||
|
return " ".join(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def render_disabled_line(line: str) -> str:
|
||||||
|
if has_unsafe_control_characters(line):
|
||||||
|
raise SafetyRefusalError("cron job line must not contain NUL or newline")
|
||||||
|
if line.startswith(DISABLED_PREFIX):
|
||||||
|
return line
|
||||||
|
return f"{DISABLED_PREFIX}{line}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_enabled_line(line: str) -> str:
|
||||||
|
if not line.startswith(DISABLED_PREFIX):
|
||||||
|
raise SafetyRefusalError("cron job line is not disabled by schedls")
|
||||||
|
return line[len(DISABLED_PREFIX) :]
|
||||||
|
|
||||||
|
|
||||||
def validate_run_as(user: str) -> str:
|
def validate_run_as(user: str) -> str:
|
||||||
@@ -147,8 +256,16 @@ def validate_system_name(name: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def render_system_line(expression: str, command: Command, user: str) -> str:
|
def render_system_line(
|
||||||
return f"{validate_expression(expression)} {validate_run_as(user)} {render_command(command)}"
|
expression: str,
|
||||||
|
command: Command,
|
||||||
|
user: str,
|
||||||
|
*,
|
||||||
|
environment: tuple[tuple[str, str], ...] = (),
|
||||||
|
) -> str:
|
||||||
|
return (
|
||||||
|
f"{validate_expression(expression)} {validate_run_as(user)} {render_command(command, environment=environment)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_block(name: str, lines: list[str], *, extra: str = "") -> str:
|
def render_block(name: str, lines: list[str], *, extra: str = "") -> str:
|
||||||
|
|||||||
+89
-6
@@ -1,14 +1,31 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from schedls.cli import _parse_environment, _spec_for_edit, _spec_for_new, build_parser, main, split_command
|
from schedls.backends.cron import CronBackend
|
||||||
|
from schedls.backends.systemd import SystemdBackend
|
||||||
|
from schedls.cli import (
|
||||||
|
Context,
|
||||||
|
_cmd_set_enabled,
|
||||||
|
_parse_environment,
|
||||||
|
_spec_for_edit,
|
||||||
|
_spec_for_new,
|
||||||
|
build_parser,
|
||||||
|
main,
|
||||||
|
split_command,
|
||||||
|
)
|
||||||
from schedls.errors import InvalidNameError, UsageError
|
from schedls.errors import InvalidNameError, UsageError
|
||||||
|
from schedls.interact import Interaction
|
||||||
from schedls.models import Backend, Command, CronDetails, JobSource, Schedule, ScheduledJob, ScheduleKind, Scope
|
from schedls.models import Backend, Command, CronDetails, JobSource, Schedule, ScheduledJob, ScheduleKind, Scope
|
||||||
|
from schedls.output import Output
|
||||||
|
|
||||||
|
from ..conftest import FakeRunner
|
||||||
|
|
||||||
|
|
||||||
def test_split_command() -> None:
|
def test_split_command() -> None:
|
||||||
@@ -86,6 +103,16 @@ def test_spec_for_new_cron() -> None:
|
|||||||
assert spec.cron_expression == "0 4 * * 0"
|
assert spec.cron_expression == "0 4 * * 0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_for_new_cron_accepts_environment() -> None:
|
||||||
|
args = build_parser().parse_args(
|
||||||
|
["new", "cleanup", "--cron", "--cron-expr", "0 4 * * 0", "--env", "GREETING=hello world", "--env", "RATE=100%"]
|
||||||
|
)
|
||||||
|
|
||||||
|
spec = _spec_for_new(args, ["/usr/local/bin/cleanup"])
|
||||||
|
|
||||||
|
assert spec.environment == (("GREETING", "hello world"), ("RATE", "100%"))
|
||||||
|
|
||||||
|
|
||||||
def test_spec_for_new_system_scope() -> None:
|
def test_spec_for_new_system_scope() -> None:
|
||||||
args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--system"])
|
args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--system"])
|
||||||
spec = _spec_for_new(args, ["/bin/true"])
|
spec = _spec_for_new(args, ["/bin/true"])
|
||||||
@@ -137,7 +164,7 @@ def test_spec_for_new_shell_mode() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_spec_for_new_rejects_systemd_flags_for_cron() -> None:
|
def test_spec_for_new_rejects_systemd_flags_for_cron() -> None:
|
||||||
for extra in (["--env", "A=1"], ["--working-directory", "/srv/data"], ["--persistent"], ["--jitter", "5min"]):
|
for extra in (["--working-directory", "/srv/data"], ["--persistent"], ["--jitter", "5min"]):
|
||||||
args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", *extra])
|
args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", *extra])
|
||||||
with pytest.raises(UsageError):
|
with pytest.raises(UsageError):
|
||||||
_spec_for_new(args, ["/bin/true"])
|
_spec_for_new(args, ["/bin/true"])
|
||||||
@@ -174,7 +201,7 @@ def test_spec_for_new_rejects_run_as_for_timer() -> None:
|
|||||||
_spec_for_new(args, ["/bin/true"])
|
_spec_for_new(args, ["/bin/true"])
|
||||||
|
|
||||||
|
|
||||||
def _cron_job() -> ScheduledJob:
|
def _cron_job(*, environment: tuple[tuple[str, str], ...] = ()) -> ScheduledJob:
|
||||||
return ScheduledJob(
|
return ScheduledJob(
|
||||||
name="cleanup",
|
name="cleanup",
|
||||||
backend=Backend.CRON,
|
backend=Backend.CRON,
|
||||||
@@ -184,19 +211,21 @@ def _cron_job() -> ScheduledJob:
|
|||||||
schedule=Schedule(ScheduleKind.CRON, "0 2 * * *"),
|
schedule=Schedule(ScheduleKind.CRON, "0 2 * * *"),
|
||||||
command=Command(raw="/usr/local/bin/cleanup"),
|
command=Command(raw="/usr/local/bin/cleanup"),
|
||||||
source=JobSource("current user's crontab", line=1),
|
source=JobSource("current user's crontab", line=1),
|
||||||
cron=CronDetails(expression="0 2 * * *", line=1),
|
cron=CronDetails(expression="0 2 * * *", line=1, environment=environment),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_spec_for_edit_cron_preserves_command_and_changes_schedule() -> None:
|
def test_spec_for_edit_cron_preserves_command_and_changes_schedule() -> None:
|
||||||
args = build_parser().parse_args(["edit", "cleanup", "--daily", "03:00"])
|
args = build_parser().parse_args(["edit", "cleanup", "--daily", "03:00"])
|
||||||
job = _cron_job()
|
job = _cron_job(environment=(("OLD", "value"),))
|
||||||
|
|
||||||
spec = _spec_for_edit(job, args, [])
|
spec = _spec_for_edit(job, args, [])
|
||||||
|
|
||||||
assert spec.backend is Backend.CRON
|
assert spec.backend is Backend.CRON
|
||||||
assert spec.cron_expression == "0 3 * * *"
|
assert spec.cron_expression == "0 3 * * *"
|
||||||
assert spec.command == job.command
|
assert spec.command == job.command
|
||||||
|
assert spec.environment == (("OLD", "value"),)
|
||||||
|
assert spec.replace_environment is False
|
||||||
|
|
||||||
|
|
||||||
def test_spec_for_edit_cron_replaces_command() -> None:
|
def test_spec_for_edit_cron_replaces_command() -> None:
|
||||||
@@ -207,7 +236,61 @@ def test_spec_for_edit_cron_replaces_command() -> None:
|
|||||||
assert spec.command.argv == ("/usr/local/bin/cleanup", "--full")
|
assert spec.command.argv == ("/usr/local/bin/cleanup", "--full")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("extra", [["--calendar", "daily"], ["--persistent"], ["--env", "A=1"]])
|
def test_spec_for_edit_cron_replaces_environment() -> None:
|
||||||
|
args = build_parser().parse_args(["edit", "cleanup", "--env", "NEW=value", "--env", "EMPTY="])
|
||||||
|
|
||||||
|
spec = _spec_for_edit(_cron_job(environment=(("OLD", "value"),)), args, [])
|
||||||
|
|
||||||
|
assert spec.environment == (("NEW", "value"), ("EMPTY", ""))
|
||||||
|
assert spec.replace_environment is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_for_edit_cron_clears_environment() -> None:
|
||||||
|
args = build_parser().parse_args(["edit", "cleanup", "--clear-env"])
|
||||||
|
|
||||||
|
spec = _spec_for_edit(_cron_job(environment=(("OLD", "value"),)), args, [])
|
||||||
|
|
||||||
|
assert spec.environment == ()
|
||||||
|
assert spec.replace_environment is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_environment_options_are_mutually_exclusive() -> None:
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
build_parser().parse_args(["edit", "cleanup", "--env", "NEW=value", "--clear-env"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_managed_cron_job_through_cli() -> None:
|
||||||
|
state = {"text": ("# schedls:begin name=backup\n0 2 * * * /usr/bin/backup\n# schedls:end name=backup\n")}
|
||||||
|
|
||||||
|
def crontab(argv, input_text):
|
||||||
|
if argv == ["crontab", "-l"]:
|
||||||
|
return state["text"], ""
|
||||||
|
if argv == ["crontab", "-"]:
|
||||||
|
state["text"] = input_text or ""
|
||||||
|
return "", ""
|
||||||
|
return 1, "", "unsupported"
|
||||||
|
|
||||||
|
runner = FakeRunner(handlers={"crontab": crontab}, available={"crontab"})
|
||||||
|
stream = io.StringIO()
|
||||||
|
output = Output(color="never", stdout=stream)
|
||||||
|
context = Context(
|
||||||
|
runner=runner,
|
||||||
|
output=output,
|
||||||
|
interaction=Interaction(output=output, assume_yes=True),
|
||||||
|
systemd=SystemdBackend(runner),
|
||||||
|
cron=CronBackend(runner),
|
||||||
|
)
|
||||||
|
dry_run_args = argparse.Namespace(name="backup", dry_run=True, show_files=False)
|
||||||
|
args = argparse.Namespace(name="backup", dry_run=False, show_files=False)
|
||||||
|
|
||||||
|
assert _cmd_set_enabled(context, dry_run_args, False) == 0
|
||||||
|
assert state["text"] == ("# schedls:begin name=backup\n0 2 * * * /usr/bin/backup\n# schedls:end name=backup\n")
|
||||||
|
assert _cmd_set_enabled(context, args, False) == 0
|
||||||
|
assert "# schedls:disabled 0 2 * * * /usr/bin/backup" in state["text"]
|
||||||
|
assert "Disabled backup." in stream.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("extra", [["--calendar", "daily"], ["--persistent"]])
|
||||||
def test_spec_for_edit_cron_rejects_systemd_options(extra: list[str]) -> None:
|
def test_spec_for_edit_cron_rejects_systemd_options(extra: list[str]) -> None:
|
||||||
args = build_parser().parse_args(["edit", "cleanup", *extra])
|
args = build_parser().parse_args(["edit", "cleanup", *extra])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ from schedls.backends.base import FileChange, Plan
|
|||||||
from schedls.backends.cron import CronBackend, CronPaths, CrontabDocument
|
from schedls.backends.cron import CronBackend, CronPaths, CrontabDocument
|
||||||
from schedls.errors import ConflictError, InvalidScheduleError, SafetyRefusalError
|
from schedls.errors import ConflictError, InvalidScheduleError, SafetyRefusalError
|
||||||
from schedls.models import Backend, Command, JobSpec, Scope
|
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
|
from schedls.renderers import cron as renderer
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ def test_discovers_system_crontabs(tmp_path, fake_runner) -> None:
|
|||||||
_write(tmp_path / "etc/cron.d/backup", "0 2 * * * root /usr/bin/backup\n")
|
_write(tmp_path / "etc/cron.d/backup", "0 2 * * * root /usr/bin/backup\n")
|
||||||
_write(
|
_write(
|
||||||
tmp_path / "etc/cron.d/schedls-cleanup",
|
tmp_path / "etc/cron.d/schedls-cleanup",
|
||||||
renderer.render_system_file("cleanup", ["30 3 * * 0 deploy /usr/bin/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/with.dot", "0 2 * * * root /usr/bin/ignored\n")
|
||||||
_write(tmp_path / "etc/cron.d/.placeholder", "")
|
_write(tmp_path / "etc/cron.d/.placeholder", "")
|
||||||
@@ -70,9 +73,353 @@ def test_discovers_system_crontabs(tmp_path, fake_runner) -> None:
|
|||||||
assert managed.managed is True
|
assert managed.managed is True
|
||||||
assert managed.cron.user == "deploy"
|
assert managed.cron.user == "deploy"
|
||||||
assert managed.schedule.expression == "30 3 * * 0"
|
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"
|
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:
|
def test_discovers_periodic_directories(tmp_path, fake_runner) -> None:
|
||||||
paths = _make_paths(tmp_path)
|
paths = _make_paths(tmp_path)
|
||||||
_write(tmp_path / "etc/cron.daily/logrotate", "#!/bin/sh\n", mode=0o755)
|
_write(tmp_path / "etc/cron.daily/logrotate", "#!/bin/sh\n", mode=0o755)
|
||||||
@@ -128,6 +475,20 @@ def test_malformed_drop_in_is_unmanaged(tmp_path, fake_runner) -> None:
|
|||||||
assert jobs[0].warnings
|
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:
|
def _system_spec(**overrides) -> JobSpec:
|
||||||
values = {
|
values = {
|
||||||
"name": "backup",
|
"name": "backup",
|
||||||
@@ -141,6 +502,58 @@ def _system_spec(**overrides) -> JobSpec:
|
|||||||
return JobSpec(**values)
|
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:
|
def test_plan_create_system_cron(tmp_path, fake_runner, monkeypatch) -> None:
|
||||||
paths = _make_paths(tmp_path)
|
paths = _make_paths(tmp_path)
|
||||||
(tmp_path / "etc/cron.d").mkdir(parents=True, exist_ok=True)
|
(tmp_path / "etc/cron.d").mkdir(parents=True, exist_ok=True)
|
||||||
@@ -158,6 +571,20 @@ def test_plan_create_system_cron(tmp_path, fake_runner, monkeypatch) -> None:
|
|||||||
assert ("Run as", "www-data") in plan.summary
|
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:
|
def test_plan_create_system_cron_requires_root(tmp_path, fake_runner, monkeypatch) -> None:
|
||||||
paths = _make_paths(tmp_path)
|
paths = _make_paths(tmp_path)
|
||||||
monkeypatch.setattr(os, "geteuid", lambda: 1000)
|
monkeypatch.setattr(os, "geteuid", lambda: 1000)
|
||||||
@@ -185,7 +612,7 @@ def test_update_user_cron_preserves_existing_command(tmp_path, fake_runner) -> N
|
|||||||
document = CrontabDocument(
|
document = CrontabDocument(
|
||||||
"# unrelated comment\n"
|
"# unrelated comment\n"
|
||||||
"# schedls:begin name=backup\n"
|
"# schedls:begin name=backup\n"
|
||||||
"0 2 * * * /usr/bin/printf '50\\%s\\n'\n"
|
"0 2 * * * GREETING='hello world' /usr/bin/printf '50\\%s\\n'\n"
|
||||||
"# schedls:end name=backup\n"
|
"# schedls:end name=backup\n"
|
||||||
)
|
)
|
||||||
state = {"text": document.text}
|
state = {"text": document.text}
|
||||||
@@ -216,7 +643,7 @@ def test_update_user_cron_preserves_existing_command(tmp_path, fake_runner) -> N
|
|||||||
|
|
||||||
assert plan.action == "update"
|
assert plan.action == "update"
|
||||||
assert "# unrelated comment" in plan.payload["new_text"]
|
assert "# unrelated comment" in plan.payload["new_text"]
|
||||||
assert "30 3 * * * /usr/bin/printf '50\\%s\\n'" 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"]
|
assert "50\\\\%" not in plan.payload["new_text"]
|
||||||
state["text"] = state["text"].replace("0 2 * * *", "15 2 * * *")
|
state["text"] = state["text"].replace("0 2 * * *", "15 2 * * *")
|
||||||
with pytest.raises(SafetyRefusalError, match="crontab changed"):
|
with pytest.raises(SafetyRefusalError, match="crontab changed"):
|
||||||
@@ -237,6 +664,78 @@ def test_update_user_cron_preserves_existing_command(tmp_path, fake_runner) -> N
|
|||||||
assert state["text"] == plan.payload["new_text"]
|
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:
|
def test_plan_update_system_cron_preserves_run_as_and_other_content(tmp_path, fake_runner, monkeypatch) -> None:
|
||||||
paths = _make_paths(tmp_path)
|
paths = _make_paths(tmp_path)
|
||||||
path = _write(
|
path = _write(
|
||||||
@@ -271,6 +770,42 @@ def test_plan_update_system_cron_preserves_run_as_and_other_content(tmp_path, fa
|
|||||||
assert trusted_directories == [(paths.cron_d, 0)]
|
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:
|
def test_plan_update_system_cron_requires_root(tmp_path, fake_runner, monkeypatch) -> None:
|
||||||
paths = _make_paths(tmp_path)
|
paths = _make_paths(tmp_path)
|
||||||
_write(
|
_write(
|
||||||
@@ -339,6 +874,32 @@ def test_apply_file_mode_create_and_remove(tmp_path, fake_runner) -> None:
|
|||||||
assert not path.exists()
|
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:
|
def test_apply_file_update_refuses_changed_snapshot(tmp_path, fake_runner) -> None:
|
||||||
paths = _make_paths(tmp_path)
|
paths = _make_paths(tmp_path)
|
||||||
_write(tmp_path / "etc/cron.d/schedls-backup", "changed\n")
|
_write(tmp_path / "etc/cron.d/schedls-backup", "changed\n")
|
||||||
@@ -423,3 +984,5 @@ def test_plan_remove_refuses_unmanaged(tmp_path, fake_runner) -> None:
|
|||||||
job = backend.discover([Scope.SYSTEM])[0]
|
job = backend.discover([Scope.SYSTEM])[0]
|
||||||
with pytest.raises(SafetyRefusalError):
|
with pytest.raises(SafetyRefusalError):
|
||||||
backend.plan_remove(job)
|
backend.plan_remove(job)
|
||||||
|
with pytest.raises(SafetyRefusalError):
|
||||||
|
backend.plan_set_enabled(job, False)
|
||||||
@@ -82,6 +82,24 @@ def test_with_updated_job_preserves_all_other_content() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggling_managed_job_preserves_escaped_command_text() -> None:
|
||||||
|
original = "# schedls:begin name=backup\n0 2 * * * /usr/bin/printf '50\\%s\\n'\n# schedls:end name=backup\n"
|
||||||
|
|
||||||
|
disabled = CrontabDocument(original).with_toggled_enabled("backup", False)
|
||||||
|
|
||||||
|
assert "# schedls:disabled 0 2 * * * /usr/bin/printf '50\\%s\\n'" in disabled
|
||||||
|
assert CrontabDocument(disabled).with_toggled_enabled("backup", True) == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggling_managed_job_preserves_absent_trailing_newline() -> None:
|
||||||
|
original = "# schedls:begin name=backup\n0 2 * * * /usr/bin/backup\n# schedls:end name=backup"
|
||||||
|
|
||||||
|
disabled = CrontabDocument(original).with_toggled_enabled("backup", False)
|
||||||
|
|
||||||
|
assert not disabled.endswith("\n")
|
||||||
|
assert CrontabDocument(disabled).with_toggled_enabled("backup", True) == original
|
||||||
|
|
||||||
|
|
||||||
def test_with_updated_job_requires_exactly_one_job() -> None:
|
def test_with_updated_job_requires_exactly_one_job() -> None:
|
||||||
document = CrontabDocument(
|
document = CrontabDocument(
|
||||||
"# schedls:begin name=backup\n"
|
"# schedls:begin name=backup\n"
|
||||||
@@ -111,6 +129,53 @@ def test_malformed_marker_blocks_mutation() -> None:
|
|||||||
document.with_updated_job("broken", "0 3 * * * /bin/true")
|
document.with_updated_job("broken", "0 3 * * * /bin/true")
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_disabled_marker_blocks_mutation() -> None:
|
||||||
|
document = CrontabDocument(
|
||||||
|
"# schedls:begin name=backup\n# schedls:disabled not a cron job\n# schedls:end name=backup\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert document.has_malformed_markers()
|
||||||
|
assert "line 2" in document.warnings[0]
|
||||||
|
with pytest.raises(SafetyRefusalError):
|
||||||
|
document.with_toggled_enabled("backup", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_indented_disabled_marker_inside_managed_block_blocks_mutation() -> None:
|
||||||
|
document = CrontabDocument(
|
||||||
|
"# schedls:begin name=backup\n # schedls:disabled 0 2 * * * /usr/bin/backup\n# schedls:end name=backup\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert document.has_malformed_markers()
|
||||||
|
with pytest.raises(SafetyRefusalError):
|
||||||
|
document.with_toggled_enabled("backup", True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("marker", ["# schedls:disabled", "# schedls:disabled "])
|
||||||
|
def test_empty_disabled_marker_inside_managed_block_blocks_mutation(marker: str) -> None:
|
||||||
|
document = CrontabDocument(f"# schedls:begin name=backup\n{marker}\n# schedls:end name=backup\n")
|
||||||
|
|
||||||
|
assert document.has_malformed_markers()
|
||||||
|
with pytest.raises(SafetyRefusalError):
|
||||||
|
document.with_toggled_enabled("backup", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_disabled_marker_outside_managed_block_does_not_block_mutation() -> None:
|
||||||
|
document = CrontabDocument("# schedls:disabled not a cron job\n")
|
||||||
|
|
||||||
|
assert not document.has_malformed_markers()
|
||||||
|
assert "name=backup" in document.with_block("backup", ["0 2 * * * /usr/bin/backup"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsafe_environment_prefix_inside_managed_block_blocks_mutation() -> None:
|
||||||
|
document = CrontabDocument(
|
||||||
|
"# schedls:begin name=backup\n0 2 * * * VALUE=bad\x00value /usr/bin/backup\n# schedls:end name=backup\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert document.has_malformed_markers()
|
||||||
|
with pytest.raises(SafetyRefusalError):
|
||||||
|
document.with_updated_job("backup", "30 3 * * * /usr/bin/backup")
|
||||||
|
|
||||||
|
|
||||||
def test_empty_crontab() -> None:
|
def test_empty_crontab() -> None:
|
||||||
document = CrontabDocument("")
|
document = CrontabDocument("")
|
||||||
assert document.render() == ""
|
assert document.render() == ""
|
||||||
|
|||||||
@@ -118,6 +118,30 @@ def test_render_show_explains_unavailable_cron_history() -> None:
|
|||||||
assert status.index("active") == source.index("current user's crontab")
|
assert status.index("active") == source.index("current user's crontab")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_show_displays_cron_environment() -> None:
|
||||||
|
job = ScheduledJob(
|
||||||
|
name="cron-1",
|
||||||
|
backend=Backend.CRON,
|
||||||
|
scope=Scope.USER,
|
||||||
|
managed=True,
|
||||||
|
enabled=True,
|
||||||
|
schedule=Schedule(ScheduleKind.CRON, "0 2 * * *"),
|
||||||
|
command=Command(raw="/usr/bin/backup"),
|
||||||
|
source=JobSource("current user's crontab", line=1),
|
||||||
|
cron=CronDetails(
|
||||||
|
expression="0 2 * * *",
|
||||||
|
line=1,
|
||||||
|
environment=(("GREETING", "hello world"),),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
output, stream = _output()
|
||||||
|
|
||||||
|
inspect_ops.render_show(output, job)
|
||||||
|
|
||||||
|
assert "Environment" in stream.getvalue()
|
||||||
|
assert "GREETING=hello world" in stream.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def test_render_show_keeps_cron_next_run_null_in_json() -> None:
|
def test_render_show_keeps_cron_next_run_null_in_json() -> None:
|
||||||
job = ScheduledJob(
|
job = ScheduledJob(
|
||||||
name="cron-1",
|
name="cron-1",
|
||||||
@@ -329,7 +353,13 @@ def test_plan_update_refuses_unmanaged() -> None:
|
|||||||
backend.plan_update(unmanaged, spec)
|
backend.plan_update(unmanaged, spec)
|
||||||
|
|
||||||
|
|
||||||
def _managed_job(name: str = "backup", *, scope: Scope = Scope.USER, service_unit: str | None = None) -> ScheduledJob:
|
def _managed_job(
|
||||||
|
name: str = "backup",
|
||||||
|
*,
|
||||||
|
scope: Scope = Scope.USER,
|
||||||
|
service_unit: str | None = None,
|
||||||
|
environment: tuple[tuple[str, str], ...] = (),
|
||||||
|
) -> ScheduledJob:
|
||||||
return replace(
|
return replace(
|
||||||
_job(name),
|
_job(name),
|
||||||
scope=scope,
|
scope=scope,
|
||||||
@@ -337,17 +367,25 @@ def _managed_job(name: str = "backup", *, scope: Scope = Scope.USER, service_uni
|
|||||||
timer_unit=f"schedls-{name}.timer",
|
timer_unit=f"schedls-{name}.timer",
|
||||||
service_unit=service_unit or f"schedls-{name}.service",
|
service_unit=service_unit or f"schedls-{name}.service",
|
||||||
on_calendar=("*-*-* 02:00:00",),
|
on_calendar=("*-*-* 02:00:00",),
|
||||||
|
environment=environment,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _spec(name: str = "backup") -> JobSpec:
|
def _spec(
|
||||||
|
name: str = "backup",
|
||||||
|
*,
|
||||||
|
environment: tuple[tuple[str, str], ...] = (),
|
||||||
|
replace_environment: bool = False,
|
||||||
|
) -> JobSpec:
|
||||||
return JobSpec(
|
return JobSpec(
|
||||||
name=name,
|
name=name,
|
||||||
backend=Backend.SYSTEMD,
|
backend=Backend.SYSTEMD,
|
||||||
scope=Scope.USER,
|
scope=Scope.USER,
|
||||||
command=Command(argv=("/bin/true",)),
|
command=Command(argv=("/bin/true",)),
|
||||||
calendar=("*-*-* 03:00:00",),
|
calendar=("*-*-* 03:00:00",),
|
||||||
|
environment=environment,
|
||||||
|
replace_environment=replace_environment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -373,6 +411,43 @@ def test_plan_update_derives_unit_names(tmp_path, monkeypatch) -> None:
|
|||||||
assert "evil" not in " ".join(change.path for change in plan.files)
|
assert "evil" not in " ".join(change.path for change in plan.files)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_update_systemd_can_clear_environment(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||||
|
backend = SystemdBackend(_analyze_runner())
|
||||||
|
|
||||||
|
plan = backend.plan_update(
|
||||||
|
_managed_job(environment=(("OLD", "value"),)),
|
||||||
|
_spec(environment=(), replace_environment=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = next(change.content for change in plan.files if change.path.endswith(".service"))
|
||||||
|
assert "Environment=" not in service
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_update_systemd_replaces_environment(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||||
|
backend = SystemdBackend(_analyze_runner())
|
||||||
|
|
||||||
|
plan = backend.plan_update(
|
||||||
|
_managed_job(environment=(("OLD", "value"),)),
|
||||||
|
_spec(environment=(("NEW", "value"),), replace_environment=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = next(change.content for change in plan.files if change.path.endswith(".service"))
|
||||||
|
assert 'Environment="NEW=value"' in service
|
||||||
|
assert "OLD=value" not in service
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_update_systemd_preserves_environment_without_replacement(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||||
|
backend = SystemdBackend(_analyze_runner())
|
||||||
|
|
||||||
|
plan = backend.plan_update(_managed_job(environment=(("OLD", "value"),)), _spec())
|
||||||
|
|
||||||
|
service = next(change.content for change in plan.files if change.path.endswith(".service"))
|
||||||
|
assert 'Environment="OLD=value"' in service
|
||||||
|
|
||||||
|
|
||||||
def test_verify_units_rejects_traversal() -> None:
|
def test_verify_units_rejects_traversal() -> None:
|
||||||
backend = SystemdBackend(_analyze_runner())
|
backend = SystemdBackend(_analyze_runner())
|
||||||
with pytest.raises(SafetyRefusalError):
|
with pytest.raises(SafetyRefusalError):
|
||||||
|
|||||||
+76
-17
@@ -47,7 +47,7 @@ def make_prompter(answers: list[str], *, calendar_validator=None) -> tuple[Promp
|
|||||||
return Prompter(output=output, input_fn=fake, calendar_validator=calendar_validator), fake
|
return Prompter(output=output, input_fn=fake, calendar_validator=calendar_validator), fake
|
||||||
|
|
||||||
|
|
||||||
def _systemd_job() -> ScheduledJob:
|
def _systemd_job(*, environment: tuple[tuple[str, str], ...] = ()) -> ScheduledJob:
|
||||||
return ScheduledJob(
|
return ScheduledJob(
|
||||||
name="backup",
|
name="backup",
|
||||||
backend=Backend.SYSTEMD,
|
backend=Backend.SYSTEMD,
|
||||||
@@ -57,11 +57,11 @@ def _systemd_job() -> ScheduledJob:
|
|||||||
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"),
|
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"),
|
||||||
command=Command(argv=("/usr/local/bin/backup",)),
|
command=Command(argv=("/usr/local/bin/backup",)),
|
||||||
source=JobSource(detail="systemd user timer"),
|
source=JobSource(detail="systemd user timer"),
|
||||||
systemd=SystemdDetails(on_calendar=("*-*-* 02:00:00",)),
|
systemd=SystemdDetails(on_calendar=("*-*-* 02:00:00",), environment=environment),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cron_job() -> ScheduledJob:
|
def _cron_job(*, environment: tuple[tuple[str, str], ...] = ()) -> ScheduledJob:
|
||||||
return ScheduledJob(
|
return ScheduledJob(
|
||||||
name="cleanup",
|
name="cleanup",
|
||||||
backend=Backend.CRON,
|
backend=Backend.CRON,
|
||||||
@@ -71,7 +71,7 @@ def _cron_job() -> ScheduledJob:
|
|||||||
schedule=Schedule(ScheduleKind.CRON, "0 2 * * *"),
|
schedule=Schedule(ScheduleKind.CRON, "0 2 * * *"),
|
||||||
command=Command(raw="/usr/local/bin/cleanup"),
|
command=Command(raw="/usr/local/bin/cleanup"),
|
||||||
source=JobSource(detail="current user's crontab"),
|
source=JobSource(detail="current user's crontab"),
|
||||||
cron=CronDetails(expression="0 2 * * *"),
|
cron=CronDetails(expression="0 2 * * *", environment=environment),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ def test_eof_raises_confirmation_required() -> None:
|
|||||||
def test_wizard_new_systemd(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wizard_new_systemd(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "-i"])
|
args = build_parser().parse_args(["new", "-i"])
|
||||||
answers = ["backup", "", "", "n", "/usr/local/bin/backup /srv/data", "", "02:00", "n"]
|
answers = ["backup", "", "", "n", "/usr/local/bin/backup /srv/data", "", "02:00", "n", "n"]
|
||||||
prompter, _ = make_prompter(answers)
|
prompter, _ = make_prompter(answers)
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
@@ -179,7 +179,7 @@ def test_wizard_new_systemd(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
def test_wizard_new_cron(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wizard_new_cron(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "-i"])
|
args = build_parser().parse_args(["new", "-i"])
|
||||||
answers = ["cleanup", "2", "", "n", "/usr/local/bin/cleanup", "", "04:00"]
|
answers = ["cleanup", "2", "", "n", "/usr/local/bin/cleanup", "", "04:00", "n"]
|
||||||
prompter, _ = make_prompter(answers)
|
prompter, _ = make_prompter(answers)
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
@@ -189,20 +189,31 @@ def test_wizard_new_cron(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert spec.run_as is None
|
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:
|
def test_wizard_new_skips_provided_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00", "--user"])
|
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00", "--user"])
|
||||||
prompter, fake = make_prompter(["n"])
|
prompter, fake = make_prompter(["n", "n"])
|
||||||
args, tail = _wizard_new(args, ["/bin/true"], prompter)
|
args, tail = _wizard_new(args, ["/bin/true"], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
assert spec.calendar == ("*-*-* 02:00:00",)
|
assert spec.calendar == ("*-*-* 02:00:00",)
|
||||||
assert fake.prompts == ["Set advanced timer options? [y/N] "]
|
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:
|
def test_wizard_new_scope_prompt_defaults_to_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "cleanup", "-i", "--cron", "--daily", "04:00"])
|
args = build_parser().parse_args(["new", "cleanup", "-i", "--cron", "--daily", "04:00"])
|
||||||
prompter, fake = make_prompter(["", "n", "/usr/local/bin/cleanup"])
|
prompter, fake = make_prompter(["", "n", "/usr/local/bin/cleanup", "n"])
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
assert spec.scope is Scope.USER
|
assert spec.scope is Scope.USER
|
||||||
@@ -214,7 +225,7 @@ def test_wizard_new_scope_prompt_defaults_to_user(monkeypatch: pytest.MonkeyPatc
|
|||||||
def test_wizard_new_system_cron_prompts_run_as(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wizard_new_system_cron_prompts_run_as(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
||||||
prompter, _ = make_prompter(["2", "www-data", "n", "/usr/local/bin/report"])
|
prompter, _ = make_prompter(["2", "www-data", "n", "/usr/local/bin/report", "n"])
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
assert spec.scope is Scope.SYSTEM
|
assert spec.scope is Scope.SYSTEM
|
||||||
@@ -224,7 +235,7 @@ def test_wizard_new_system_cron_prompts_run_as(monkeypatch: pytest.MonkeyPatch)
|
|||||||
def test_wizard_new_system_cron_run_as_defaults_to_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wizard_new_system_cron_run_as_defaults_to_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
||||||
prompter, _ = make_prompter(["2", "", "n", "/usr/local/bin/report"])
|
prompter, _ = make_prompter(["2", "", "n", "/usr/local/bin/report", "n"])
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
assert spec.scope is Scope.SYSTEM
|
assert spec.scope is Scope.SYSTEM
|
||||||
@@ -234,7 +245,7 @@ def test_wizard_new_system_cron_run_as_defaults_to_root(monkeypatch: pytest.Monk
|
|||||||
def test_wizard_new_system_cron_run_as_reprompts(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wizard_new_system_cron_run_as_reprompts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "report", "-i", "--cron", "--daily", "04:00"])
|
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"])
|
prompter, fake = make_prompter(["2", "bad user", "www-data", "n", "/usr/local/bin/report", "n"])
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
assert spec.run_as == "www-data"
|
assert spec.run_as == "www-data"
|
||||||
@@ -246,7 +257,7 @@ def test_wizard_new_provided_scope_and_run_as_are_not_reasked(monkeypatch: pytes
|
|||||||
args = build_parser().parse_args(
|
args = build_parser().parse_args(
|
||||||
["new", "report", "-i", "--cron", "--system", "--run-as", "root", "--daily", "04:00"]
|
["new", "report", "-i", "--cron", "--system", "--run-as", "root", "--daily", "04:00"]
|
||||||
)
|
)
|
||||||
prompter, fake = make_prompter(["n", "/usr/local/bin/report"])
|
prompter, fake = make_prompter(["n", "/usr/local/bin/report", "n"])
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
assert spec.scope is Scope.SYSTEM
|
assert spec.scope is Scope.SYSTEM
|
||||||
@@ -257,7 +268,7 @@ def test_wizard_new_provided_scope_and_run_as_are_not_reasked(monkeypatch: pytes
|
|||||||
def test_wizard_new_systemd_scope_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wizard_new_systemd_scope_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00"])
|
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00"])
|
||||||
prompter, _ = make_prompter(["2", "n", "/usr/local/bin/backup", "n"])
|
prompter, _ = make_prompter(["2", "n", "/usr/local/bin/backup", "n", "n"])
|
||||||
args, tail = _wizard_new(args, [], prompter)
|
args, tail = _wizard_new(args, [], prompter)
|
||||||
spec = _spec_for_new(args, tail)
|
spec = _spec_for_new(args, tail)
|
||||||
assert spec.scope is Scope.SYSTEM
|
assert spec.scope is Scope.SYSTEM
|
||||||
@@ -268,7 +279,7 @@ def test_wizard_edit_changes_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
job = _systemd_job()
|
job = _systemd_job()
|
||||||
args = build_parser().parse_args(["edit", "backup", "-i"])
|
args = build_parser().parse_args(["edit", "backup", "-i"])
|
||||||
answers = ["n", "y", "", "03:00", "n"]
|
answers = ["n", "y", "", "03:00", "n", "n"]
|
||||||
prompter, _ = make_prompter(answers)
|
prompter, _ = make_prompter(answers)
|
||||||
args, tail = _wizard_edit(job, args, [], prompter)
|
args, tail = _wizard_edit(job, args, [], prompter)
|
||||||
spec = _spec_for_edit(job, args, tail)
|
spec = _spec_for_edit(job, args, tail)
|
||||||
@@ -278,19 +289,67 @@ def test_wizard_edit_changes_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
def test_wizard_edit_changes_cron_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_wizard_edit_changes_cron_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
args = build_parser().parse_args(["edit", "cleanup", "-i"])
|
args = build_parser().parse_args(["edit", "cleanup", "-i"])
|
||||||
prompter, _ = make_prompter(["n", "y", "", "03:00"])
|
prompter, _ = make_prompter(["n", "y", "", "03:00", "n"])
|
||||||
args, tail = _wizard_edit(_cron_job(), args, [], prompter)
|
args, tail = _wizard_edit(_cron_job(), args, [], prompter)
|
||||||
spec = _spec_for_edit(_cron_job(), args, tail)
|
spec = _spec_for_edit(_cron_job(), args, tail)
|
||||||
|
|
||||||
assert spec.cron_expression == "0 3 * * *"
|
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:
|
def test_wizard_edit_keeps_changes_optional(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||||
job = _systemd_job()
|
job = _systemd_job()
|
||||||
args = build_parser().parse_args(["edit", "backup", "-i"])
|
args = build_parser().parse_args(["edit", "backup", "-i"])
|
||||||
prompter, _ = make_prompter(["n", "n", "n"])
|
prompter, _ = make_prompter(["n", "n", "n", "n"])
|
||||||
args, tail = _wizard_edit(job, args, [], prompter)
|
args, tail = _wizard_edit(job, args, [], prompter)
|
||||||
spec = _spec_for_edit(job, args, tail)
|
spec = _spec_for_edit(job, args, tail)
|
||||||
assert spec.calendar == ("*-*-* 02:00:00",)
|
assert spec.calendar == ("*-*-* 02:00:00",)
|
||||||
assert tail == []
|
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
|
||||||
@@ -77,6 +77,64 @@ def test_expression_percent_not_double_escaped() -> None:
|
|||||||
assert line == "0 2 * * * /bin/echo a\\%b"
|
assert line == "0 2 * * * /bin/echo a\\%b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_line_with_execution_local_environment() -> None:
|
||||||
|
line = renderer.render_line(
|
||||||
|
"0 2 * * *",
|
||||||
|
Command(argv=("/bin/echo", "a%b")),
|
||||||
|
environment=(("GREETING", "hello world"), ("RATE", "100%")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert line == "0 2 * * * GREETING='hello world' RATE=100\\% /bin/echo a\\%b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_shell_line_with_environment_keeps_leading_assignment_semantics() -> None:
|
||||||
|
line = renderer.render_line(
|
||||||
|
"0 2 * * *",
|
||||||
|
Command(shell=True, raw="printf '%s' \"$GREETING\" | cat"),
|
||||||
|
environment=(("GREETING", "hello world"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert line == "0 2 * * * GREETING='hello world' printf '\\%s' \"$GREETING\" | cat"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"hello world",
|
||||||
|
"a'b",
|
||||||
|
r"back\\slash",
|
||||||
|
"$HOME",
|
||||||
|
"#hash",
|
||||||
|
"a=b",
|
||||||
|
"unicode-é中😀",
|
||||||
|
"%",
|
||||||
|
"50% off",
|
||||||
|
r"a\%b c",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_execution_local_environment_prefix_round_trips(value: str) -> None:
|
||||||
|
command = renderer.render_command(Command(argv=("/bin/true",)), environment=(("VALUE", value),))
|
||||||
|
|
||||||
|
parsed = renderer.parse_environment_prefix(command)
|
||||||
|
|
||||||
|
assert parsed.malformed is False
|
||||||
|
assert parsed.environment == (("VALUE", value),)
|
||||||
|
assert parsed.command == "/bin/true"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["bad\nvalue", "bad\rvalue", "bad\x00value"])
|
||||||
|
def test_execution_local_environment_rejects_unsafe_control_characters(value: str) -> None:
|
||||||
|
with pytest.raises(SafetyRefusalError):
|
||||||
|
renderer.render_command(Command(argv=("/bin/true",)), environment=(("VALUE", value),))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_environment_prefix_rejects_unescaped_percent() -> None:
|
||||||
|
parsed = renderer.parse_environment_prefix("VALUE='50% off' /bin/true")
|
||||||
|
|
||||||
|
assert parsed.malformed is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"bad",
|
"bad",
|
||||||
["", "0 2 * *", "0 2 * * * *", "60 2 * * *", "0 25 * * *", "0 2 32 * *", "@bogus", "a b c d e"],
|
["", "0 2 * *", "0 2 * * * *", "60 2 * * *", "0 25 * * *", "0 2 32 * *", "@bogus", "a b c d e"],
|
||||||
|
|||||||
Reference in new issue
Block a user