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.
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/CHANGELOG.md +33 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/PKG-INFO +2 -2
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/pyproject.toml +1 -1
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/__init__.py +13 -0
- runspec_linux_core-0.6.0/runspec_linux_core/containers.py +102 -0
- runspec_linux_core-0.6.0/runspec_linux_core/cron.py +368 -0
- runspec_linux_core-0.6.0/tests/test_containers_runas.py +68 -0
- runspec_linux_core-0.6.0/tests/test_cron.py +252 -0
- runspec_linux_core-0.4.0/runspec_linux_core/containers.py +0 -88
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/.gitignore +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/_paths.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/become.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/commands.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/errors.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/files.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/investigate.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/logs.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/nc.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/network.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/packages.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/perf.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/power.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/security.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/services.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/sudoers.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/system.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/runspec_linux_core/venvs.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/__init__.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_become.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_commands.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_investigate.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_logs_services_runas.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_nc_send.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_network.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_packages.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_paths.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_perf.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_power.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_preconditions.py +0 -0
- {runspec_linux_core-0.4.0 → runspec_linux_core-0.6.0}/tests/test_sudoers.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: runspec-linux-core
|
|
3
|
-
Version: 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.
|
|
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}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|