runspec-linux-core 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/CHANGELOG.md +33 -0
  2. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/PKG-INFO +2 -2
  3. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/__init__.py +13 -0
  5. runspec_linux_core-0.6.0/runspec_linux_core/containers.py +102 -0
  6. runspec_linux_core-0.6.0/runspec_linux_core/cron.py +368 -0
  7. runspec_linux_core-0.6.0/tests/test_containers_runas.py +68 -0
  8. runspec_linux_core-0.6.0/tests/test_cron.py +252 -0
  9. runspec_linux_core-0.4.0/runspec_linux_core/containers.py +0 -88
  10. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/.gitignore +0 -0
  11. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/_paths.py +0 -0
  12. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/become.py +0 -0
  13. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/commands.py +0 -0
  14. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/errors.py +0 -0
  15. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/files.py +0 -0
  16. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/investigate.py +0 -0
  17. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/logs.py +0 -0
  18. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/nc.py +0 -0
  19. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/network.py +0 -0
  20. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/packages.py +0 -0
  21. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/perf.py +0 -0
  22. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/power.py +0 -0
  23. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/security.py +0 -0
  24. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/services.py +0 -0
  25. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/sudoers.py +0 -0
  26. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/system.py +0 -0
  27. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/venvs.py +0 -0
  28. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/__init__.py +0 -0
  29. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_become.py +0 -0
  30. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_commands.py +0 -0
  31. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_investigate.py +0 -0
  32. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_logs_services_runas.py +0 -0
  33. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_nc_send.py +0 -0
  34. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_network.py +0 -0
  35. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_packages.py +0 -0
  36. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_paths.py +0 -0
  37. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_perf.py +0 -0
  38. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_power.py +0 -0
  39. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_preconditions.py +0 -0
  40. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_sudoers.py +0 -0
  41. {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_venvs.py +0 -0
@@ -1,5 +1,38 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.6.0] — 2026-08-24
4
+
5
+ New `runspec_linux_core.cron` module — pure helpers for managing a user's
6
+ crontab: `list_cron`, `add_cron`, `remove_cron`, `clear_cron`, plus the
7
+ `validate_schedule` shape check. Each takes the same keyword-only `run_as` /
8
+ `become_method` escalation as the container/venv helpers and wraps its
9
+ `crontab` subprocess with `build_become`, so "run as `bob`" reads/writes
10
+ `bob`'s own crontab (`sudo -n -u bob crontab -l` / `crontab -`); with no
11
+ `run_as` the caller's crontab is used. Editing is a read-modify-write (crontab
12
+ has no append/delete verbs): the whole new table is piped to `crontab -`.
13
+ `list_cron` returns structured job records (schedule, command, an attached
14
+ `# comment`, `special` for `@nickname` schedules) plus environment
15
+ assignments; a user with no crontab is reported `exists: false`, not an error.
16
+ `add_cron` is idempotent (an identical `schedule command` line is left as-is
17
+ unless `allow_duplicate`); `remove_cron` selects by command `match` or job
18
+ `index` and drops a preceding `# comment` line with the job. Schedules,
19
+ commands, and comments are guarded against newline injection. Failures raise
20
+ `ToolNotFoundError` (no `crontab`), `CommandError` (non-zero subprocess, with
21
+ the passwordless-sudo hint), or `ValueError` (bad input).
22
+
23
+
24
+ ## [0.5.0] — 2026-08-07
25
+
26
+ The Docker container helpers now take the same per-invocation `run_as` /
27
+ `become_method` escalation as the log/service helpers, since the Docker daemon
28
+ socket is typically root- or `docker`-group-owned. `list_containers`,
29
+ `container_logs`, and `restart_container` gain keyword-only `run_as` /
30
+ `become_method` params and wrap their `docker` subprocess with `build_become`;
31
+ a failed escalated command raises `CommandError` with the passwordless-sudo hint
32
+ (`container_logs` no longer returns the sudo error as log lines when `run_as` is
33
+ set). No behaviour change when `run_as` is unset.
34
+
35
+
3
36
  ## [0.4.0] — 2026-08-01
4
37
 
5
38
  New shared privilege-escalation module `runspec_linux_core.become` — the single
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: runspec-linux-core
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux-core"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)"
10
10
  dependencies = []
@@ -23,6 +23,13 @@ from runspec_linux_core.containers import (
23
23
  list_containers,
24
24
  restart_container,
25
25
  )
26
+ from runspec_linux_core.cron import (
27
+ add_cron,
28
+ clear_cron,
29
+ list_cron,
30
+ remove_cron,
31
+ validate_schedule,
32
+ )
26
33
  from runspec_linux_core.errors import CommandError, LinuxCoreError, ToolNotFoundError
27
34
  from runspec_linux_core.files import backup_files, find_large_files
28
35
  from runspec_linux_core.investigate import (
@@ -111,6 +118,12 @@ __all__ = [
111
118
  "list_containers",
112
119
  "container_logs",
113
120
  "restart_container",
121
+ # cron
122
+ "list_cron",
123
+ "add_cron",
124
+ "remove_cron",
125
+ "clear_cron",
126
+ "validate_schedule",
114
127
  # packages
115
128
  "detect_manager",
116
129
  "list_packages",
@@ -0,0 +1,102 @@
1
+ """Docker container helpers: list, logs, restart.
2
+
3
+ The Docker daemon socket is typically root-owned (or restricted to the ``docker``
4
+ group), so each helper takes an optional ``run_as`` user (with a
5
+ ``become_method``). When set, the ``docker`` command is escalated via
6
+ ``sudo -n -u`` / ``su`` (:func:`runspec_linux_core.become.build_become`) — a
7
+ ``--sudo`` flag on the runnable maps to ``run_as="root"``. With no ``run_as`` the
8
+ command runs as the calling user (unchanged).
9
+ """
10
+
11
+ import shutil
12
+ import subprocess
13
+
14
+ from runspec_linux_core.become import build_become, check_become_error
15
+ from runspec_linux_core.errors import ToolNotFoundError
16
+
17
+
18
+ def _docker_available() -> bool:
19
+ return shutil.which("docker") is not None
20
+
21
+
22
+ def list_containers(include_all: bool = False, *, run_as: str | None = None, become_method: str = "sudo") -> list[dict]:
23
+ """Return Docker containers (running, or all if ``include_all``).
24
+
25
+ Raises ToolNotFoundError if docker is not installed, or CommandError if the
26
+ ``docker ps`` command returns a non-zero exit (with a passwordless-sudo hint
27
+ when ``run_as`` is set).
28
+ """
29
+ if not _docker_available():
30
+ raise ToolNotFoundError("docker not installed or not in PATH")
31
+
32
+ cmd = [
33
+ "docker",
34
+ "ps",
35
+ "--format",
36
+ "{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Names}}",
37
+ ]
38
+ if include_all:
39
+ cmd.append("--all")
40
+
41
+ result = subprocess.run(build_become(cmd, run_as, become_method), capture_output=True, text=True)
42
+ if result.returncode != 0:
43
+ check_become_error("list containers", result.stderr, run_as)
44
+
45
+ rows = []
46
+ for line in result.stdout.strip().splitlines():
47
+ parts = line.split("\t")
48
+ if len(parts) < 6:
49
+ continue
50
+ rows.append(
51
+ {
52
+ "id": parts[0],
53
+ "image": parts[1],
54
+ "command": parts[2].strip('"'),
55
+ "created": parts[3],
56
+ "status": parts[4],
57
+ "name": parts[5],
58
+ }
59
+ )
60
+ return rows
61
+
62
+
63
+ def container_logs(container: str, lines: int = 50, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
64
+ """Return the last ``lines`` log lines for a container.
65
+
66
+ Raises ToolNotFoundError if docker is not installed. When ``run_as`` is set
67
+ and the escalated command fails, raises CommandError with a passwordless-sudo
68
+ hint rather than returning the sudo error as log lines.
69
+ """
70
+ if not _docker_available():
71
+ raise ToolNotFoundError("docker not installed or not in PATH")
72
+
73
+ argv = build_become(["docker", "logs", "--tail", str(lines), container], run_as, become_method)
74
+ result = subprocess.run(argv, capture_output=True, text=True)
75
+ if run_as and result.returncode != 0:
76
+ check_become_error("container logs", result.stderr, run_as)
77
+ # docker logs writes to stderr by default
78
+ output = result.stderr if result.stderr else result.stdout
79
+ output_lines = output.strip().splitlines()
80
+ out = {"container": container, "lines": output_lines, "count": len(output_lines)}
81
+ if run_as:
82
+ out["run_as"] = run_as
83
+ return out
84
+
85
+
86
+ def restart_container(container: str, *, run_as: str | None = None, become_method: str = "sudo") -> dict:
87
+ """Restart a container. Returns ``{container, restarted: True}`` on success.
88
+
89
+ Raises ToolNotFoundError if docker is not installed, or CommandError if the
90
+ restart returns a non-zero exit (with a passwordless-sudo hint when
91
+ ``run_as`` is set).
92
+ """
93
+ if not _docker_available():
94
+ raise ToolNotFoundError("docker not installed or not in PATH")
95
+
96
+ result = subprocess.run(build_become(["docker", "restart", container], run_as, become_method), capture_output=True, text=True)
97
+ if result.returncode != 0:
98
+ check_become_error("restart container", result.stderr, run_as)
99
+ out = {"container": container, "restarted": True}
100
+ if run_as:
101
+ out["run_as"] = run_as
102
+ return out
@@ -0,0 +1,368 @@
1
+ """Cron (per-user crontab) management: list, add, remove, clear jobs.
2
+
3
+ Every helper drives the ``crontab`` binary and manages **one user's crontab**.
4
+ Which user is chosen *per invocation* via the shared ``run_as`` escalation
5
+ (mechanism B, like the venv/container helpers): with ``run_as`` set, the
6
+ ``crontab`` command is wrapped in ``sudo -n -u <user>`` / ``su <user> -c`` by
7
+ :func:`runspec_linux_core.become.build_become`, so ``crontab -l`` reads and
8
+ ``crontab -`` writes *that user's* own crontab. With no ``run_as`` the caller's
9
+ own crontab is used. ``-n`` makes sudo fail fast instead of hanging on the
10
+ TTY-less SSH channel; passwordless sudo is assumed (see ``enable-passwordless-sudo``).
11
+
12
+ Editing a crontab is a read-modify-write: ``crontab -l`` to read the current
13
+ table, then the whole new table is piped to ``crontab -`` (crontab has no
14
+ append/delete verbs of its own). Schedules, commands, and comments are guarded
15
+ against newline injection so a crafted value can't smuggle extra crontab lines.
16
+
17
+ Each function returns plain data and raises on failure (``ToolNotFoundError``
18
+ when ``crontab`` is absent, ``CommandError`` for a non-zero subprocess,
19
+ ``ValueError`` for bad input). The thin ``runspec-linux`` wrappers render the
20
+ JSON/exit.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+
29
+ from runspec_linux_core.become import build_become, check_become_error, current_user
30
+ from runspec_linux_core.errors import ToolNotFoundError
31
+
32
+ # The nickname schedules cron accepts in place of the 5 time fields.
33
+ _SPECIALS = frozenset(
34
+ {
35
+ "@reboot",
36
+ "@yearly",
37
+ "@annually",
38
+ "@monthly",
39
+ "@weekly",
40
+ "@daily",
41
+ "@midnight",
42
+ "@hourly",
43
+ }
44
+ )
45
+
46
+ # Characters allowed in a single cron time field (digits, ``*``, ranges, steps,
47
+ # lists, and month/day-of-week names). Deliberately permissive — cron itself is
48
+ # the final arbiter of a field's meaning; this only rejects obvious garbage and
49
+ # stray whitespace so we don't write a malformed table.
50
+ _FIELD_RE = re.compile(r"^[0-9*/,\-A-Za-z]+$")
51
+
52
+ # An environment assignment line in a crontab (``NAME=value``), as cron treats it.
53
+ _ENV_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$")
54
+
55
+
56
+ def _crontab_available() -> bool:
57
+ return shutil.which("crontab") is not None
58
+
59
+
60
+ def _no_such_crontab(stderr: str) -> bool:
61
+ """True when ``crontab -l`` failed only because the user has no crontab yet
62
+ (an empty table, not a real error)."""
63
+ return "no crontab for" in stderr.lower()
64
+
65
+
66
+ def _reject_newline(value: str, what: str) -> str:
67
+ if "\n" in value or "\r" in value:
68
+ raise ValueError(f"{what} must not contain a newline")
69
+ return value
70
+
71
+
72
+ def validate_schedule(schedule: str) -> str:
73
+ """Return ``schedule`` unchanged if it is a well-formed cron schedule — a
74
+ recognised ``@nickname`` or exactly five whitespace-separated time fields —
75
+ else raise :class:`ValueError`.
76
+
77
+ This is a shape check, not a full semantic validation: each field only has to
78
+ use cron's grammar characters. crontab itself rejects out-of-range values.
79
+ """
80
+ _reject_newline(schedule, "schedule")
81
+ text = schedule.strip()
82
+ if not text:
83
+ raise ValueError("empty schedule")
84
+ if text.startswith("@"):
85
+ if text.lower() not in _SPECIALS:
86
+ raise ValueError(f"unknown schedule nickname: {text!r} (expected one of {', '.join(sorted(_SPECIALS))})")
87
+ return text.lower()
88
+ fields = text.split()
89
+ if len(fields) != 5:
90
+ raise ValueError(f"schedule needs 5 fields (min hour day-of-month month day-of-week) or an @nickname; got {len(fields)}: {text!r}")
91
+ for field in fields:
92
+ if not _FIELD_RE.match(field):
93
+ raise ValueError(f"invalid character in schedule field {field!r}")
94
+ return " ".join(fields)
95
+
96
+
97
+ def _parse_line(line: str) -> dict:
98
+ """Classify one crontab line into a typed record (job / special / env /
99
+ comment / blank / unknown). Job/special records carry ``schedule`` +
100
+ ``command``; the caller stitches on any preceding comment."""
101
+ stripped = line.strip()
102
+ if not stripped:
103
+ return {"kind": "blank"}
104
+ if stripped.startswith("#"):
105
+ return {"kind": "comment", "text": stripped.lstrip("#").strip(), "raw": line}
106
+ if stripped.startswith("@"):
107
+ parts = stripped.split(None, 1)
108
+ nickname = parts[0].lower()
109
+ if nickname in _SPECIALS and len(parts) == 2:
110
+ return {"kind": "special", "schedule": nickname, "command": parts[1], "raw": line}
111
+ return {"kind": "unknown", "raw": line}
112
+ env = _ENV_RE.match(stripped)
113
+ if env:
114
+ return {"kind": "env", "name": env.group(1), "value": env.group(2).strip(), "raw": line}
115
+ parts = stripped.split(None, 5)
116
+ if len(parts) == 6:
117
+ return {"kind": "job", "schedule": " ".join(parts[:5]), "command": parts[5], "raw": line}
118
+ return {"kind": "unknown", "raw": line}
119
+
120
+
121
+ def _read_crontab(run_as: str | None, become_method: str) -> tuple[list[str], bool]:
122
+ """Return ``(lines, exists)`` for the target user's current crontab.
123
+
124
+ ``exists`` is False (and ``lines`` empty) when the user simply has no crontab
125
+ yet — that is the normal empty state, not an error. Raises CommandError on a
126
+ real ``crontab -l`` failure (with a passwordless-sudo hint when escalating).
127
+ """
128
+ result = subprocess.run(build_become(["crontab", "-l"], run_as, become_method), capture_output=True, text=True)
129
+ if result.returncode != 0:
130
+ if _no_such_crontab(result.stderr):
131
+ return [], False
132
+ check_become_error("read crontab", result.stderr, run_as)
133
+ return result.stdout.splitlines(), True
134
+
135
+
136
+ def _write_crontab(lines: list[str], run_as: str | None, become_method: str) -> None:
137
+ """Install ``lines`` as the target user's whole crontab via ``crontab -``.
138
+ Raises CommandError on failure (with a passwordless-sudo hint when escalating)."""
139
+ content = "\n".join(lines)
140
+ if content and not content.endswith("\n"):
141
+ content += "\n"
142
+ result = subprocess.run(
143
+ build_become(["crontab", "-"], run_as, become_method),
144
+ input=content,
145
+ capture_output=True,
146
+ text=True,
147
+ )
148
+ if result.returncode != 0:
149
+ check_become_error("write crontab", result.stderr, run_as)
150
+
151
+
152
+ def _target_user(run_as: str | None) -> str:
153
+ return run_as or current_user()
154
+
155
+
156
+ def list_cron(*, run_as: str | None = None, become_method: str = "sudo") -> dict:
157
+ """List the jobs in a user's crontab as structured records.
158
+
159
+ Reads ``crontab -l`` (as ``run_as`` when set) and parses it. A ``#`` comment
160
+ line directly above a job is attached to that job as its ``comment`` (the
161
+ convention :func:`add_cron` writes). Environment assignments are returned
162
+ separately. A user with no crontab yet is reported as ``exists: false`` with
163
+ no jobs — not an error.
164
+
165
+ Returns ``{user, run_as, exists, jobs: [{index, schedule, command, comment,
166
+ special, raw}], env: [{name, value}], count}``. Raises ToolNotFoundError if
167
+ ``crontab`` is not installed, CommandError on a real read failure.
168
+ """
169
+ if not _crontab_available():
170
+ raise ToolNotFoundError("crontab not installed or not in PATH")
171
+
172
+ raw_lines, exists = _read_crontab(run_as, become_method)
173
+ jobs: list[dict] = []
174
+ env: list[dict] = []
175
+ pending_comment: str | None = None
176
+ index = 0
177
+ for line in raw_lines:
178
+ rec = _parse_line(line)
179
+ kind = rec["kind"]
180
+ if kind == "comment":
181
+ pending_comment = rec["text"]
182
+ elif kind in ("job", "special"):
183
+ jobs.append(
184
+ {
185
+ "index": index,
186
+ "schedule": rec["schedule"],
187
+ "command": rec["command"],
188
+ "comment": pending_comment,
189
+ "special": kind == "special",
190
+ "raw": rec["raw"],
191
+ }
192
+ )
193
+ index += 1
194
+ pending_comment = None
195
+ elif kind == "env":
196
+ env.append({"name": rec["name"], "value": rec["value"]})
197
+ pending_comment = None
198
+ else: # blank / unknown — drop any dangling comment association
199
+ pending_comment = None
200
+
201
+ return {
202
+ "user": _target_user(run_as),
203
+ "run_as": run_as or "",
204
+ "exists": exists,
205
+ "jobs": jobs,
206
+ "env": env,
207
+ "count": len(jobs),
208
+ }
209
+
210
+
211
+ def add_cron(
212
+ schedule: str,
213
+ command: str,
214
+ *,
215
+ comment: str | None = None,
216
+ run_as: str | None = None,
217
+ become_method: str = "sudo",
218
+ allow_duplicate: bool = False,
219
+ ) -> dict:
220
+ """Add a job (``schedule command``) to a user's crontab.
221
+
222
+ ``schedule`` is validated (a 5-field spec or an ``@nickname``); ``command`` is
223
+ written verbatim (cron runs it via ``/bin/sh``). When ``comment`` is given it
224
+ is written as a ``# comment`` line directly above the job, so
225
+ :func:`list_cron` reads it back as the job's ``comment``. Unless
226
+ ``allow_duplicate`` is set, an identical ``schedule command`` line already
227
+ present is left as-is (idempotent) and reported with ``added: false``.
228
+
229
+ Returns ``{user, run_as, schedule, command, comment, added, count}``. Raises
230
+ ToolNotFoundError if ``crontab`` is absent, ValueError on bad input,
231
+ CommandError on a read/write failure.
232
+ """
233
+ if not _crontab_available():
234
+ raise ToolNotFoundError("crontab not installed or not in PATH")
235
+
236
+ schedule = validate_schedule(schedule)
237
+ command = _reject_newline(command.strip(), "command")
238
+ if not command:
239
+ raise ValueError("empty command")
240
+ if comment is not None:
241
+ comment = _reject_newline(comment.strip(), "comment") or None
242
+
243
+ lines, _ = _read_crontab(run_as, become_method)
244
+ new_line = f"{schedule} {command}"
245
+
246
+ if not allow_duplicate and any(line.strip() == new_line for line in lines):
247
+ listing = list_cron(run_as=run_as, become_method=become_method)
248
+ return {
249
+ "user": _target_user(run_as),
250
+ "run_as": run_as or "",
251
+ "schedule": schedule,
252
+ "command": command,
253
+ "comment": comment,
254
+ "added": False,
255
+ "count": listing["count"],
256
+ }
257
+
258
+ if lines and lines[-1].strip() != "":
259
+ # keep a clean separation; harmless if a blank already trails
260
+ pass
261
+ if comment:
262
+ lines.append(f"# {comment}")
263
+ lines.append(new_line)
264
+ _write_crontab(lines, run_as, become_method)
265
+
266
+ listing = list_cron(run_as=run_as, become_method=become_method)
267
+ return {
268
+ "user": _target_user(run_as),
269
+ "run_as": run_as or "",
270
+ "schedule": schedule,
271
+ "command": command,
272
+ "comment": comment,
273
+ "added": True,
274
+ "count": listing["count"],
275
+ }
276
+
277
+
278
+ def remove_cron(
279
+ *,
280
+ match: str | None = None,
281
+ index: int | None = None,
282
+ run_as: str | None = None,
283
+ become_method: str = "sudo",
284
+ ) -> dict:
285
+ """Remove job(s) from a user's crontab.
286
+
287
+ Exactly one selector must be given:
288
+
289
+ - ``index`` — remove the job at that 0-based position among the crontab's
290
+ jobs (the ``index`` reported by :func:`list_cron`); a ``# comment`` line
291
+ directly above it is removed too.
292
+ - ``match`` — remove every job whose command contains ``match`` as a
293
+ substring, or whose full ``schedule command`` line equals it; each removed
294
+ job's preceding ``# comment`` line is removed too.
295
+
296
+ Removing nothing is not an error (``removed: []``); the crontab is only
297
+ rewritten when something was actually removed. Returns ``{user, run_as,
298
+ removed: [{schedule, command}], count}`` (``count`` = jobs remaining). Raises
299
+ ToolNotFoundError if ``crontab`` is absent, ValueError on a bad selector,
300
+ CommandError on a read/write failure.
301
+ """
302
+ if not _crontab_available():
303
+ raise ToolNotFoundError("crontab not installed or not in PATH")
304
+ if (index is None) == (match is None):
305
+ raise ValueError("give exactly one of index or match")
306
+
307
+ lines, exists = _read_crontab(run_as, become_method)
308
+
309
+ # Walk the raw lines, tagging each job with its 0-based job index and the raw
310
+ # line-number of a comment directly above it, so we can drop both together.
311
+ job_index = 0
312
+ drop_lines: set[int] = set()
313
+ removed: list[dict] = []
314
+ prev_comment_lineno: int | None = None
315
+ for lineno, line in enumerate(lines):
316
+ rec = _parse_line(line)
317
+ kind = rec["kind"]
318
+ if kind == "comment":
319
+ prev_comment_lineno = lineno
320
+ continue
321
+ if kind in ("job", "special"):
322
+ if index is not None:
323
+ selected = index == job_index
324
+ else:
325
+ assert match is not None # guaranteed by the XOR selector check above
326
+ selected = match in rec["command"] or line.strip() == match.strip()
327
+ if selected:
328
+ drop_lines.add(lineno)
329
+ if prev_comment_lineno is not None:
330
+ drop_lines.add(prev_comment_lineno)
331
+ removed.append({"schedule": rec["schedule"], "command": rec["command"]})
332
+ job_index += 1
333
+ prev_comment_lineno = None
334
+
335
+ if index is not None and not removed:
336
+ raise ValueError(f"no job at index {index} (crontab has {job_index} job(s))")
337
+
338
+ if removed:
339
+ kept = [line for lineno, line in enumerate(lines) if lineno not in drop_lines]
340
+ _write_crontab(kept, run_as, become_method)
341
+
342
+ listing = list_cron(run_as=run_as, become_method=become_method) if exists else {"count": 0}
343
+ return {
344
+ "user": _target_user(run_as),
345
+ "run_as": run_as or "",
346
+ "removed": removed,
347
+ "count": listing["count"],
348
+ }
349
+
350
+
351
+ def clear_cron(*, run_as: str | None = None, become_method: str = "sudo") -> dict:
352
+ """Remove a user's entire crontab (``crontab -r``).
353
+
354
+ A no-op (reported ``existed: false``) when the user has no crontab. Returns
355
+ ``{user, run_as, cleared, existed}``. Raises ToolNotFoundError if ``crontab``
356
+ is absent, CommandError on a real removal failure.
357
+ """
358
+ if not _crontab_available():
359
+ raise ToolNotFoundError("crontab not installed or not in PATH")
360
+
361
+ _, exists = _read_crontab(run_as, become_method)
362
+ if not exists:
363
+ return {"user": _target_user(run_as), "run_as": run_as or "", "cleared": False, "existed": False}
364
+
365
+ result = subprocess.run(build_become(["crontab", "-r"], run_as, become_method), capture_output=True, text=True)
366
+ if result.returncode != 0:
367
+ check_become_error("clear crontab", result.stderr, run_as)
368
+ return {"user": _target_user(run_as), "run_as": run_as or "", "cleared": True, "existed": True}
@@ -0,0 +1,68 @@
1
+ """The run_as / become_method escalation on the Docker container helpers.
2
+
3
+ The subprocess layer is stubbed so no real docker runs; the tests assert the
4
+ escalated argv (the ``sudo -n -u`` / ``su`` prefix) and that no escalation
5
+ happens when run_as is unset.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+
12
+ import pytest
13
+
14
+ from runspec_linux_core import containers
15
+
16
+
17
+ def _stub(monkeypatch: pytest.MonkeyPatch, stdout: str = "", rc: int = 0, stderr: str = "") -> list[list[str]]:
18
+ calls: list[list[str]] = []
19
+
20
+ def fake_run(argv, capture_output=None, text=None): # noqa: A002
21
+ calls.append(argv)
22
+ return subprocess.CompletedProcess(argv, rc, stdout=stdout, stderr=stderr)
23
+
24
+ monkeypatch.setattr(containers.subprocess, "run", fake_run)
25
+ monkeypatch.setattr(containers, "_docker_available", lambda: True)
26
+ return calls
27
+
28
+
29
+ def test_list_containers_no_run_as(monkeypatch: pytest.MonkeyPatch) -> None:
30
+ calls = _stub(monkeypatch, stdout='abc\timg\t"/bin/sh"\tnow\tUp\tweb\n')
31
+ rows = containers.list_containers()
32
+ assert calls[0][0] == "docker" # no escalation prefix
33
+ assert rows[0]["name"] == "web"
34
+
35
+
36
+ def test_list_containers_run_as_escalates(monkeypatch: pytest.MonkeyPatch) -> None:
37
+ calls = _stub(monkeypatch, stdout="")
38
+ containers.list_containers(True, run_as="root")
39
+ assert calls[0][:4] == ["sudo", "-n", "-u", "root"]
40
+ assert calls[0][4:7] == ["docker", "ps", "--format"]
41
+ assert "--all" in calls[0]
42
+
43
+
44
+ def test_restart_container_run_as_escalates(monkeypatch: pytest.MonkeyPatch) -> None:
45
+ calls = _stub(monkeypatch)
46
+ out = containers.restart_container("web", run_as="deploy")
47
+ assert calls[0] == ["sudo", "-n", "-u", "deploy", "docker", "restart", "web"]
48
+ assert out == {"container": "web", "restarted": True, "run_as": "deploy"}
49
+
50
+
51
+ def test_container_logs_run_as_su(monkeypatch: pytest.MonkeyPatch) -> None:
52
+ calls = _stub(monkeypatch, stdout="log line 1\nlog line 2\n")
53
+ out = containers.container_logs("web", 10, run_as="svc", become_method="su")
54
+ assert calls[0] == ["su", "svc", "-c", "docker logs --tail 10 web"]
55
+ assert out["count"] == 2
56
+ assert out["run_as"] == "svc"
57
+
58
+
59
+ def test_container_logs_run_as_passwordless_hint(monkeypatch: pytest.MonkeyPatch) -> None:
60
+ _stub(monkeypatch, rc=1, stderr="sudo: a password is required")
61
+ with pytest.raises(Exception, match="passwordless sudo is not configured for run-as 'svc'"):
62
+ containers.container_logs("web", run_as="svc")
63
+
64
+
65
+ def test_restart_container_run_as_passwordless_hint(monkeypatch: pytest.MonkeyPatch) -> None:
66
+ _stub(monkeypatch, rc=1, stderr="sudo: a terminal is required")
67
+ with pytest.raises(Exception, match="passwordless sudo is not configured for run-as 'svc'"):
68
+ containers.restart_container("web", run_as="svc")
@@ -0,0 +1,252 @@
1
+ """Cron helpers: parsing, validation, the read-modify-write, and run_as escalation.
2
+
3
+ The subprocess layer is stubbed with an in-memory fake crontab so no real
4
+ ``crontab`` runs; the tests assert the parsed records, the schedule guards, the
5
+ rewritten table piped to ``crontab -``, and the escalated argv (the
6
+ ``sudo -n -u`` / ``su`` prefix) for another user.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+
13
+ import pytest
14
+
15
+ from runspec_linux_core import cron
16
+ from runspec_linux_core.errors import CommandError, ToolNotFoundError
17
+
18
+
19
+ class FakeCrontab:
20
+ """A one-user in-memory crontab driving the stubbed ``subprocess.run``.
21
+
22
+ Recognises the three crontab invocations by the tokens present in argv (so it
23
+ works for both the plain ``["crontab", "-l"]`` form and the
24
+ ``su user -c "crontab -l"`` form): ``-l`` reads, ``-r`` removes, bare ``-``
25
+ installs the table piped on stdin. Records every argv for escalation asserts.
26
+ """
27
+
28
+ def __init__(self, lines: list[str] | None = None, exists: bool = True) -> None:
29
+ self.lines = lines or []
30
+ self.exists = exists
31
+ self.calls: list[list[str]] = []
32
+
33
+ def run(self, argv, capture_output=None, text=None, input=None): # noqa: A002
34
+ self.calls.append(argv)
35
+ joined = " ".join(argv)
36
+ if "crontab -l" in joined:
37
+ if not self.exists:
38
+ return subprocess.CompletedProcess(argv, 1, stdout="", stderr="no crontab for bob")
39
+ return subprocess.CompletedProcess(argv, 0, stdout="\n".join(self.lines) + ("\n" if self.lines else ""), stderr="")
40
+ if "crontab -r" in joined:
41
+ self.lines = []
42
+ self.exists = False
43
+ return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
44
+ # bare "crontab -" write from stdin
45
+ self.lines = (input or "").splitlines()
46
+ self.exists = True
47
+ return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
48
+
49
+
50
+ def _install(monkeypatch: pytest.MonkeyPatch, fake: FakeCrontab) -> None:
51
+ monkeypatch.setattr(cron, "_crontab_available", lambda: True)
52
+ monkeypatch.setattr(cron.subprocess, "run", fake.run)
53
+ monkeypatch.setattr(cron, "current_user", lambda: "tester")
54
+
55
+
56
+ # ── validate_schedule ──────────────────────────────────────────────────────
57
+
58
+
59
+ @pytest.mark.parametrize("good", ["0 3 * * *", "*/5 * * * 1-5", "@reboot", "@Daily", "15 0 1 * *"])
60
+ def test_validate_schedule_ok(good: str) -> None:
61
+ # returns normalised (nicknames lowercased, whitespace collapsed)
62
+ assert cron.validate_schedule(good)
63
+
64
+
65
+ @pytest.mark.parametrize("bad", ["", "0 3 * *", "0 3 * * * extra", "@nope", "0 3 * * ;rm", "0 3 * * *\n0 0 * * *"])
66
+ def test_validate_schedule_rejects(bad: str) -> None:
67
+ with pytest.raises(ValueError):
68
+ cron.validate_schedule(bad)
69
+
70
+
71
+ # ── list_cron ──────────────────────────────────────────────────────────────
72
+
73
+
74
+ def test_list_cron_parses_jobs_env_and_comments(monkeypatch: pytest.MonkeyPatch) -> None:
75
+ fake = FakeCrontab(
76
+ [
77
+ "SHELL=/bin/bash",
78
+ "# nightly backup",
79
+ "0 3 * * * /opt/app/backup.sh",
80
+ "",
81
+ "@reboot /opt/app/warmup.sh",
82
+ ]
83
+ )
84
+ _install(monkeypatch, fake)
85
+ out = cron.list_cron()
86
+ assert out["exists"] is True
87
+ assert out["count"] == 2
88
+ assert out["env"] == [{"name": "SHELL", "value": "/bin/bash"}]
89
+ first, second = out["jobs"]
90
+ assert first == {
91
+ "index": 0,
92
+ "schedule": "0 3 * * *",
93
+ "command": "/opt/app/backup.sh",
94
+ "comment": "nightly backup",
95
+ "special": False,
96
+ "raw": "0 3 * * * /opt/app/backup.sh",
97
+ }
98
+ assert second["index"] == 1
99
+ assert second["schedule"] == "@reboot"
100
+ assert second["special"] is True
101
+ assert second["comment"] is None
102
+
103
+
104
+ def test_list_cron_no_crontab_is_empty_not_error(monkeypatch: pytest.MonkeyPatch) -> None:
105
+ fake = FakeCrontab(exists=False)
106
+ _install(monkeypatch, fake)
107
+ out = cron.list_cron(run_as="bob")
108
+ assert out == {"user": "bob", "run_as": "bob", "exists": False, "jobs": [], "env": [], "count": 0}
109
+
110
+
111
+ def test_tool_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
112
+ monkeypatch.setattr(cron, "_crontab_available", lambda: False)
113
+ with pytest.raises(ToolNotFoundError):
114
+ cron.list_cron()
115
+
116
+
117
+ # ── add_cron ───────────────────────────────────────────────────────────────
118
+
119
+
120
+ def test_add_cron_appends_and_writes(monkeypatch: pytest.MonkeyPatch) -> None:
121
+ fake = FakeCrontab(["0 1 * * * /existing"])
122
+ _install(monkeypatch, fake)
123
+ out = cron.add_cron("0 3 * * *", "/opt/app/backup.sh", comment="nightly backup")
124
+ assert out["added"] is True
125
+ assert out["count"] == 2
126
+ assert fake.lines == ["0 1 * * * /existing", "# nightly backup", "0 3 * * * /opt/app/backup.sh"]
127
+
128
+
129
+ def test_add_cron_idempotent_duplicate(monkeypatch: pytest.MonkeyPatch) -> None:
130
+ fake = FakeCrontab(["0 3 * * * /opt/app/backup.sh"])
131
+ _install(monkeypatch, fake)
132
+ out = cron.add_cron("0 3 * * *", "/opt/app/backup.sh")
133
+ assert out["added"] is False
134
+ assert out["count"] == 1
135
+ # no write happened (only reads)
136
+ assert all("crontab -l" in " ".join(c) for c in fake.calls)
137
+
138
+
139
+ def test_add_cron_allow_duplicate(monkeypatch: pytest.MonkeyPatch) -> None:
140
+ fake = FakeCrontab(["0 3 * * * /opt/app/backup.sh"])
141
+ _install(monkeypatch, fake)
142
+ out = cron.add_cron("0 3 * * *", "/opt/app/backup.sh", allow_duplicate=True)
143
+ assert out["added"] is True
144
+ assert fake.lines.count("0 3 * * * /opt/app/backup.sh") == 2
145
+
146
+
147
+ def test_add_cron_rejects_newline_in_command(monkeypatch: pytest.MonkeyPatch) -> None:
148
+ fake = FakeCrontab([])
149
+ _install(monkeypatch, fake)
150
+ with pytest.raises(ValueError, match="command must not contain a newline"):
151
+ cron.add_cron("0 3 * * *", "/bin/true\n0 0 * * * /bin/evil")
152
+
153
+
154
+ # ── remove_cron ────────────────────────────────────────────────────────────
155
+
156
+
157
+ def test_remove_cron_by_match_drops_job_and_comment(monkeypatch: pytest.MonkeyPatch) -> None:
158
+ fake = FakeCrontab(
159
+ [
160
+ "# nightly backup",
161
+ "0 3 * * * /opt/app/backup.sh",
162
+ "0 1 * * * /keep.sh",
163
+ ]
164
+ )
165
+ _install(monkeypatch, fake)
166
+ out = cron.remove_cron(match="backup.sh")
167
+ assert out["removed"] == [{"schedule": "0 3 * * *", "command": "/opt/app/backup.sh"}]
168
+ assert out["count"] == 1
169
+ assert fake.lines == ["0 1 * * * /keep.sh"]
170
+
171
+
172
+ def test_remove_cron_by_index(monkeypatch: pytest.MonkeyPatch) -> None:
173
+ fake = FakeCrontab(["0 1 * * * /a.sh", "0 2 * * * /b.sh"])
174
+ _install(monkeypatch, fake)
175
+ out = cron.remove_cron(index=1)
176
+ assert out["removed"] == [{"schedule": "0 2 * * *", "command": "/b.sh"}]
177
+ assert fake.lines == ["0 1 * * * /a.sh"]
178
+
179
+
180
+ def test_remove_cron_match_nothing_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
181
+ fake = FakeCrontab(["0 1 * * * /a.sh"])
182
+ _install(monkeypatch, fake)
183
+ out = cron.remove_cron(match="nomatch")
184
+ assert out["removed"] == []
185
+ assert out["count"] == 1
186
+ # nothing written — only reads
187
+ assert all("crontab -l" in " ".join(c) for c in fake.calls)
188
+
189
+
190
+ def test_remove_cron_bad_index_raises(monkeypatch: pytest.MonkeyPatch) -> None:
191
+ fake = FakeCrontab(["0 1 * * * /a.sh"])
192
+ _install(monkeypatch, fake)
193
+ with pytest.raises(ValueError, match="no job at index 5"):
194
+ cron.remove_cron(index=5)
195
+
196
+
197
+ def test_remove_cron_requires_exactly_one_selector(monkeypatch: pytest.MonkeyPatch) -> None:
198
+ fake = FakeCrontab([])
199
+ _install(monkeypatch, fake)
200
+ with pytest.raises(ValueError, match="exactly one of index or match"):
201
+ cron.remove_cron()
202
+ with pytest.raises(ValueError, match="exactly one of index or match"):
203
+ cron.remove_cron(match="x", index=0)
204
+
205
+
206
+ # ── clear_cron ─────────────────────────────────────────────────────────────
207
+
208
+
209
+ def test_clear_cron_removes(monkeypatch: pytest.MonkeyPatch) -> None:
210
+ fake = FakeCrontab(["0 1 * * * /a.sh"])
211
+ _install(monkeypatch, fake)
212
+ out = cron.clear_cron()
213
+ assert out == {"user": "tester", "run_as": "", "cleared": True, "existed": True}
214
+ assert any("crontab -r" in " ".join(c) for c in fake.calls)
215
+
216
+
217
+ def test_clear_cron_noop_when_absent(monkeypatch: pytest.MonkeyPatch) -> None:
218
+ fake = FakeCrontab(exists=False)
219
+ _install(monkeypatch, fake)
220
+ out = cron.clear_cron()
221
+ assert out == {"user": "tester", "run_as": "", "cleared": False, "existed": False}
222
+ assert not any("crontab -r" in " ".join(c) for c in fake.calls)
223
+
224
+
225
+ # ── run_as escalation ──────────────────────────────────────────────────────
226
+
227
+
228
+ def test_list_cron_run_as_escalates_sudo(monkeypatch: pytest.MonkeyPatch) -> None:
229
+ fake = FakeCrontab(["0 1 * * * /a.sh"])
230
+ _install(monkeypatch, fake)
231
+ cron.list_cron(run_as="deploy")
232
+ assert fake.calls[0] == ["sudo", "-n", "-u", "deploy", "crontab", "-l"]
233
+
234
+
235
+ def test_add_cron_run_as_write_escalates_su(monkeypatch: pytest.MonkeyPatch) -> None:
236
+ fake = FakeCrontab([])
237
+ _install(monkeypatch, fake)
238
+ cron.add_cron("@daily", "/x.sh", run_as="svc", become_method="su")
239
+ # read then write, both under su -c
240
+ assert fake.calls[0] == ["su", "svc", "-c", "crontab -l"]
241
+ write_call = next(c for c in fake.calls if c[:3] == ["su", "svc", "-c"] and c[3] == "crontab -")
242
+ assert write_call == ["su", "svc", "-c", "crontab -"]
243
+
244
+
245
+ def test_run_as_passwordless_hint(monkeypatch: pytest.MonkeyPatch) -> None:
246
+ def fake_run(argv, capture_output=None, text=None, input=None): # noqa: A002
247
+ return subprocess.CompletedProcess(argv, 1, stdout="", stderr="sudo: a password is required")
248
+
249
+ monkeypatch.setattr(cron, "_crontab_available", lambda: True)
250
+ monkeypatch.setattr(cron.subprocess, "run", fake_run)
251
+ with pytest.raises(CommandError, match="passwordless sudo is not configured for run-as 'svc'"):
252
+ cron.list_cron(run_as="svc")
@@ -1,88 +0,0 @@
1
- """Docker container helpers: list, logs, restart."""
2
-
3
- import shutil
4
- import subprocess
5
-
6
- from runspec_linux_core.errors import CommandError, ToolNotFoundError
7
-
8
-
9
- def _docker_available() -> bool:
10
- return shutil.which("docker") is not None
11
-
12
-
13
- def list_containers(include_all: bool = False) -> list[dict]:
14
- """Return Docker containers (running, or all if ``include_all``).
15
-
16
- Raises ToolNotFoundError if docker is not installed, or CommandError if the
17
- ``docker ps`` command returns a non-zero exit.
18
- """
19
- if not _docker_available():
20
- raise ToolNotFoundError("docker not installed or not in PATH")
21
-
22
- cmd = [
23
- "docker",
24
- "ps",
25
- "--format",
26
- "{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Names}}",
27
- ]
28
- if include_all:
29
- cmd.append("--all")
30
-
31
- result = subprocess.run(cmd, capture_output=True, text=True)
32
- if result.returncode != 0:
33
- raise CommandError(result.stderr.strip())
34
-
35
- rows = []
36
- for line in result.stdout.strip().splitlines():
37
- parts = line.split("\t")
38
- if len(parts) < 6:
39
- continue
40
- rows.append(
41
- {
42
- "id": parts[0],
43
- "image": parts[1],
44
- "command": parts[2].strip('"'),
45
- "created": parts[3],
46
- "status": parts[4],
47
- "name": parts[5],
48
- }
49
- )
50
- return rows
51
-
52
-
53
- def container_logs(container: str, lines: int = 50) -> dict:
54
- """Return the last ``lines`` log lines for a container.
55
-
56
- Raises ToolNotFoundError if docker is not installed.
57
- """
58
- if not _docker_available():
59
- raise ToolNotFoundError("docker not installed or not in PATH")
60
-
61
- result = subprocess.run(
62
- ["docker", "logs", "--tail", str(lines), container],
63
- capture_output=True,
64
- text=True,
65
- )
66
- # docker logs writes to stderr by default
67
- output = result.stderr if result.stderr else result.stdout
68
- output_lines = output.strip().splitlines()
69
- return {"container": container, "lines": output_lines, "count": len(output_lines)}
70
-
71
-
72
- def restart_container(container: str) -> dict:
73
- """Restart a container. Returns ``{container, restarted: True}`` on success.
74
-
75
- Raises ToolNotFoundError if docker is not installed, or CommandError if the
76
- restart returns a non-zero exit (message is the captured stderr).
77
- """
78
- if not _docker_available():
79
- raise ToolNotFoundError("docker not installed or not in PATH")
80
-
81
- result = subprocess.run(
82
- ["docker", "restart", container],
83
- capture_output=True,
84
- text=True,
85
- )
86
- if result.returncode != 0:
87
- raise CommandError(result.stderr.strip())
88
- return {"container": container, "restarted": True}