runspec-scheduler 0.1.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_scheduler-0.1.0/.gitignore +61 -0
- runspec_scheduler-0.1.0/CHANGELOG.md +21 -0
- runspec_scheduler-0.1.0/PKG-INFO +77 -0
- runspec_scheduler-0.1.0/README.md +62 -0
- runspec_scheduler-0.1.0/deploy/runspec-scheduler.service +29 -0
- runspec_scheduler-0.1.0/pyproject.toml +49 -0
- runspec_scheduler-0.1.0/runspec_scheduler/__init__.py +36 -0
- runspec_scheduler-0.1.0/runspec_scheduler/cli.py +186 -0
- runspec_scheduler-0.1.0/runspec_scheduler/config.py +190 -0
- runspec_scheduler-0.1.0/runspec_scheduler/daemon.py +195 -0
- runspec_scheduler-0.1.0/runspec_scheduler/discovery.py +71 -0
- runspec_scheduler-0.1.0/runspec_scheduler/envfile.py +145 -0
- runspec_scheduler-0.1.0/runspec_scheduler/runner.py +104 -0
- runspec_scheduler-0.1.0/runspec_scheduler/runspec.toml +92 -0
- runspec_scheduler-0.1.0/runspec_scheduler/service.py +73 -0
- runspec_scheduler-0.1.0/runspec_scheduler/timing.py +106 -0
- runspec_scheduler-0.1.0/tests/test_config.py +88 -0
- runspec_scheduler-0.1.0/tests/test_daemon.py +85 -0
- runspec_scheduler-0.1.0/tests/test_envfile.py +69 -0
- runspec_scheduler-0.1.0/tests/test_timing.py +86 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
.env
|
|
16
|
+
pip-wheel-metadata/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
htmlcov/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
*.cover
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
*.js.map
|
|
29
|
+
.npm
|
|
30
|
+
|
|
31
|
+
# Go
|
|
32
|
+
*.exe
|
|
33
|
+
*.test
|
|
34
|
+
*.out
|
|
35
|
+
vendor/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.iml
|
|
41
|
+
*.iws
|
|
42
|
+
*.ipr
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
# Docs
|
|
47
|
+
site/
|
|
48
|
+
|
|
49
|
+
# Misc
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
|
|
53
|
+
# External reference repos (cloned locally, not committed)
|
|
54
|
+
chainlit-docs/
|
|
55
|
+
.chainlit/
|
|
56
|
+
|
|
57
|
+
# Claude Code local config (machine-specific)
|
|
58
|
+
.claude/launch.json
|
|
59
|
+
|
|
60
|
+
# Stray committed test venv (removed from tracking)
|
|
61
|
+
.venv-test/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-scheduler` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0]
|
|
6
|
+
|
|
7
|
+
Initial release.
|
|
8
|
+
|
|
9
|
+
- A long-running per-venv scheduler daemon (`systemctl --user`) that runs runnables
|
|
10
|
+
installed in its own venv on a cron/interval cadence — independent of any desktop
|
|
11
|
+
console.
|
|
12
|
+
- Itself a runspec runnable (`discoverable = false`) with verbs: `run` (daemon),
|
|
13
|
+
`add`, `remove`, `list`, `run-now`, `install-service`.
|
|
14
|
+
- Venv-scoped config at `{sys.prefix}/runspec_scheduler.toml` (override with
|
|
15
|
+
`--config` / `$RUNSPEC_SCHEDULER_CONFIG`); atomic writes; mtime hot-reload.
|
|
16
|
+
- Timing (`cron` / `every`) mirrors runspec-console's schedule semantics exactly,
|
|
17
|
+
pinned by a parity test.
|
|
18
|
+
- Rotating audit log at `{sys.prefix}/logs/scheduler.log`.
|
|
19
|
+
- Credential support for `run_as` runnables via `RUNSPEC_ENV_FILE`: a 600 env file
|
|
20
|
+
owned by the `run_as` account (written with the secret on stdin, never argv); the
|
|
21
|
+
daemon points `RUNSPEC_ENV_FILE` at it and escalates to `run_as` when firing.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-scheduler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cron-style scheduler service for runspec runnables installed in one venv
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: apscheduler<4,>=3.10
|
|
8
|
+
Requires-Dist: runspec>=0.48.0
|
|
9
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# runspec-scheduler
|
|
17
|
+
|
|
18
|
+
**Cron, but limited to runspec runnables installed in one venv.**
|
|
19
|
+
|
|
20
|
+
A long-running per-venv scheduler daemon (`systemctl --user`) that runs runnables
|
|
21
|
+
installed in *its own venv* on a cron/interval cadence — independent of any desktop
|
|
22
|
+
console. It is itself a runspec runnable, so `runspec-console` can detect it and
|
|
23
|
+
manage its schedules over SSH, but `discoverable = false` keeps it out of the Forms
|
|
24
|
+
tab / `runspec serve` MCP surface.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
A console-driven schedule only fires while the desktop console is open, and it's
|
|
29
|
+
per-operator. `runspec-scheduler` gives you **shared, always-on** schedules that live
|
|
30
|
+
on the host and run whether or not a console is connected.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Into the same venv whose runnables you want to schedule:
|
|
36
|
+
pip install runspec-scheduler
|
|
37
|
+
|
|
38
|
+
# Install + enable the systemd --user service for this venv:
|
|
39
|
+
runspec-scheduler install-service
|
|
40
|
+
loginctl enable-linger "$USER" # survive logout
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`install-service` writes `~/.config/systemd/user/runspec-scheduler-<venv>.service`
|
|
44
|
+
(one daemon per venv) and runs `systemctl --user enable --now`. To do it by hand, see
|
|
45
|
+
`deploy/runspec-scheduler.service`.
|
|
46
|
+
|
|
47
|
+
## Use
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
runspec-scheduler add --id nightly --runnable backup --cron '0 2 * * *'
|
|
51
|
+
runspec-scheduler add --id poll --runnable healthcheck --every 15m --args '{"level":"warn"}'
|
|
52
|
+
runspec-scheduler list
|
|
53
|
+
runspec-scheduler run-now --id poll
|
|
54
|
+
runspec-scheduler remove --id poll
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- Exactly one of `--cron` (5-field cron) or `--every` (`15m` / `1h` / `1d`).
|
|
58
|
+
- `--args` is a JSON object passed to the target runnable.
|
|
59
|
+
- Adding/removing a schedule hot-reloads the running daemon (mtime poll, ≤ 5s).
|
|
60
|
+
|
|
61
|
+
## Config
|
|
62
|
+
|
|
63
|
+
Schedules live in `{venv}/runspec_scheduler.toml` (override with `--config` or
|
|
64
|
+
`$RUNSPEC_SCHEDULER_CONFIG`). The daemon keeps a rotating audit log at
|
|
65
|
+
`{venv}/logs/scheduler.log`; live logs via `journalctl --user -u runspec-scheduler-<venv> -f`.
|
|
66
|
+
|
|
67
|
+
## Credentials (`run_as` runnables)
|
|
68
|
+
|
|
69
|
+
`runspec-console` can attach service-account credentials to a remote schedule. For a
|
|
70
|
+
runnable that declares `run_as`, the selected credentials are written to a **600 env
|
|
71
|
+
file owned by the `run_as` account** in that account's home; the daemon points
|
|
72
|
+
`RUNSPEC_ENV_FILE` at it and escalates to `run_as` when firing. Credentials are never
|
|
73
|
+
placed on a command line. This requires the daemon/SSH user to have passwordless
|
|
74
|
+
`sudo -u <run_as>` (the same precondition as running that `run_as` runnable at all).
|
|
75
|
+
|
|
76
|
+
Runnables **without** `run_as` run as the daemon's own user with the daemon's
|
|
77
|
+
environment (systemd `EnvironmentFile`) — no console credential injection.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# runspec-scheduler
|
|
2
|
+
|
|
3
|
+
**Cron, but limited to runspec runnables installed in one venv.**
|
|
4
|
+
|
|
5
|
+
A long-running per-venv scheduler daemon (`systemctl --user`) that runs runnables
|
|
6
|
+
installed in *its own venv* on a cron/interval cadence — independent of any desktop
|
|
7
|
+
console. It is itself a runspec runnable, so `runspec-console` can detect it and
|
|
8
|
+
manage its schedules over SSH, but `discoverable = false` keeps it out of the Forms
|
|
9
|
+
tab / `runspec serve` MCP surface.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
A console-driven schedule only fires while the desktop console is open, and it's
|
|
14
|
+
per-operator. `runspec-scheduler` gives you **shared, always-on** schedules that live
|
|
15
|
+
on the host and run whether or not a console is connected.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Into the same venv whose runnables you want to schedule:
|
|
21
|
+
pip install runspec-scheduler
|
|
22
|
+
|
|
23
|
+
# Install + enable the systemd --user service for this venv:
|
|
24
|
+
runspec-scheduler install-service
|
|
25
|
+
loginctl enable-linger "$USER" # survive logout
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`install-service` writes `~/.config/systemd/user/runspec-scheduler-<venv>.service`
|
|
29
|
+
(one daemon per venv) and runs `systemctl --user enable --now`. To do it by hand, see
|
|
30
|
+
`deploy/runspec-scheduler.service`.
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
runspec-scheduler add --id nightly --runnable backup --cron '0 2 * * *'
|
|
36
|
+
runspec-scheduler add --id poll --runnable healthcheck --every 15m --args '{"level":"warn"}'
|
|
37
|
+
runspec-scheduler list
|
|
38
|
+
runspec-scheduler run-now --id poll
|
|
39
|
+
runspec-scheduler remove --id poll
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- Exactly one of `--cron` (5-field cron) or `--every` (`15m` / `1h` / `1d`).
|
|
43
|
+
- `--args` is a JSON object passed to the target runnable.
|
|
44
|
+
- Adding/removing a schedule hot-reloads the running daemon (mtime poll, ≤ 5s).
|
|
45
|
+
|
|
46
|
+
## Config
|
|
47
|
+
|
|
48
|
+
Schedules live in `{venv}/runspec_scheduler.toml` (override with `--config` or
|
|
49
|
+
`$RUNSPEC_SCHEDULER_CONFIG`). The daemon keeps a rotating audit log at
|
|
50
|
+
`{venv}/logs/scheduler.log`; live logs via `journalctl --user -u runspec-scheduler-<venv> -f`.
|
|
51
|
+
|
|
52
|
+
## Credentials (`run_as` runnables)
|
|
53
|
+
|
|
54
|
+
`runspec-console` can attach service-account credentials to a remote schedule. For a
|
|
55
|
+
runnable that declares `run_as`, the selected credentials are written to a **600 env
|
|
56
|
+
file owned by the `run_as` account** in that account's home; the daemon points
|
|
57
|
+
`RUNSPEC_ENV_FILE` at it and escalates to `run_as` when firing. Credentials are never
|
|
58
|
+
placed on a command line. This requires the daemon/SSH user to have passwordless
|
|
59
|
+
`sudo -u <run_as>` (the same precondition as running that `run_as` runnable at all).
|
|
60
|
+
|
|
61
|
+
Runnables **without** `run_as` run as the daemon's own user with the daemon's
|
|
62
|
+
environment (systemd `EnvironmentFile`) — no console credential injection.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# systemd --user unit for the runspec scheduler daemon.
|
|
2
|
+
#
|
|
3
|
+
# This is a template. `runspec-scheduler install-service` writes a per-venv copy
|
|
4
|
+
# named runspec-scheduler-<venv>.service with ExecStart pointing at this venv's
|
|
5
|
+
# binary — prefer that over copying this by hand. To install manually:
|
|
6
|
+
#
|
|
7
|
+
# cp deploy/runspec-scheduler.service \
|
|
8
|
+
# ~/.config/systemd/user/runspec-scheduler-<venv>.service
|
|
9
|
+
# # edit ExecStart to your venv's bin/runspec-scheduler
|
|
10
|
+
# systemctl --user daemon-reload
|
|
11
|
+
# systemctl --user enable --now runspec-scheduler-<venv>
|
|
12
|
+
# loginctl enable-linger "$USER" # keep it running after logout
|
|
13
|
+
|
|
14
|
+
[Unit]
|
|
15
|
+
Description=runspec scheduler
|
|
16
|
+
After=network-online.target
|
|
17
|
+
Wants=network-online.target
|
|
18
|
+
|
|
19
|
+
[Service]
|
|
20
|
+
Type=simple
|
|
21
|
+
WorkingDirectory=%h
|
|
22
|
+
# Point this at your venv's binary, e.g. %h/venvs/ops/bin/runspec-scheduler
|
|
23
|
+
ExecStart=%h/.local/bin/runspec-scheduler run
|
|
24
|
+
EnvironmentFile=-%h/.config/runspec-scheduler/runspec-scheduler.env
|
|
25
|
+
Restart=on-failure
|
|
26
|
+
RestartSec=5
|
|
27
|
+
|
|
28
|
+
[Install]
|
|
29
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-scheduler"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
description = "Cron-style scheduler service for runspec runnables installed in one venv"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = [
|
|
13
|
+
# 0.48.0 for the `discoverable` boolean + serve leaf globals used by the console.
|
|
14
|
+
"runspec>=0.48.0",
|
|
15
|
+
# APScheduler drives the cron/interval timing. This package's whole reason to
|
|
16
|
+
# exist is scheduling, so it's a hard dependency (unlike runspec-console, which
|
|
17
|
+
# keeps it optional under the [schedule] extra).
|
|
18
|
+
"apscheduler>=3.10,<4",
|
|
19
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
# Entry point name MUST match the runnable name in runspec.toml.
|
|
24
|
+
runspec-scheduler = "runspec_scheduler.cli:main"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"ruff==0.15.20",
|
|
29
|
+
"mypy",
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
python_version = "3.10"
|
|
38
|
+
|
|
39
|
+
[[tool.mypy.overrides]]
|
|
40
|
+
# APScheduler ships no type stubs; tomli is the 3.10-only TOML reader.
|
|
41
|
+
module = ["apscheduler.*", "tomli"]
|
|
42
|
+
ignore_missing_imports = true
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 200
|
|
46
|
+
target-version = "py310"
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""runspec-scheduler — a cron-style scheduler service for runspec runnables.
|
|
2
|
+
|
|
3
|
+
A long-running per-venv daemon (``systemctl --user``) that runs runnables
|
|
4
|
+
installed in *its own venv* on a cron/interval cadence, independent of any
|
|
5
|
+
desktop console. It is itself a runspec runnable with management verbs
|
|
6
|
+
(``run`` / ``add`` / ``remove`` / ``list`` / ``run-now`` / ``install-service``).
|
|
7
|
+
|
|
8
|
+
Public API (for corporate wrappers / tests): the pure config + timing helpers,
|
|
9
|
+
which have no runspec-console dependency.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .config import (
|
|
15
|
+
ScheduleEntry,
|
|
16
|
+
config_path,
|
|
17
|
+
load_schedules,
|
|
18
|
+
read_config,
|
|
19
|
+
upsert_schedule,
|
|
20
|
+
write_schedules,
|
|
21
|
+
)
|
|
22
|
+
from .timing import When, next_fire_time, parse_when
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ScheduleEntry",
|
|
26
|
+
"When",
|
|
27
|
+
"config_path",
|
|
28
|
+
"load_schedules",
|
|
29
|
+
"next_fire_time",
|
|
30
|
+
"parse_when",
|
|
31
|
+
"read_config",
|
|
32
|
+
"upsert_schedule",
|
|
33
|
+
"write_schedules",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""cli.py — the `runspec-scheduler` entry point.
|
|
2
|
+
|
|
3
|
+
One binary, several verbs. ``rs.parse("runspec-scheduler")`` resolves the
|
|
4
|
+
subcommand + args; we dispatch on ``spec.runspec_command``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import runspec as rs
|
|
15
|
+
|
|
16
|
+
from .config import ScheduleEntry, config_path, load_schedules, remove_schedule, upsert_schedule
|
|
17
|
+
from .daemon import configure_logging, run_daemon
|
|
18
|
+
from .discovery import resolve_target
|
|
19
|
+
from .envfile import capture_env, remove_env_file, write_env_file
|
|
20
|
+
from .runner import run_runnable, venv_name
|
|
21
|
+
from .timing import next_fire_time, parse_when
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _opt(arg: Any) -> Any:
|
|
25
|
+
"""Underlying value of a runspec ``Arg``; '' / None → None."""
|
|
26
|
+
value = getattr(arg, "value", arg)
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
if isinstance(value, str) and not value.strip():
|
|
30
|
+
return None
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
35
|
+
print(json.dumps(payload))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_json_arg(raw: Any, what: str) -> Any:
|
|
39
|
+
if raw is None:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(str(raw))
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
_emit({"error": f"invalid JSON for {what}: {exc}"})
|
|
45
|
+
sys.exit(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cfg_path(spec: rs.RunSpec) -> Path:
|
|
49
|
+
return config_path(_opt(spec.config))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cmd_run(spec: rs.RunSpec) -> None:
|
|
53
|
+
configure_logging()
|
|
54
|
+
run_daemon(_cfg_path(spec), reload_interval=int(spec.reload_interval.value))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cmd_add(spec: rs.RunSpec) -> None:
|
|
58
|
+
path = _cfg_path(spec)
|
|
59
|
+
sid = str(spec.id.value)
|
|
60
|
+
runnable = str(spec.runnable.value)
|
|
61
|
+
when = parse_when(
|
|
62
|
+
{
|
|
63
|
+
"cron": _opt(spec.cron) or "",
|
|
64
|
+
"every": _opt(spec.every) or "",
|
|
65
|
+
"timezone": _opt(spec.timezone) or "",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
args = _parse_json_arg(_opt(spec.args), "--args") or {}
|
|
69
|
+
if not isinstance(args, dict):
|
|
70
|
+
_emit({"error": "--args must be a JSON object"})
|
|
71
|
+
sys.exit(2)
|
|
72
|
+
|
|
73
|
+
target = resolve_target(runnable)
|
|
74
|
+
if target is None:
|
|
75
|
+
_emit({"error": f"runnable '{runnable}' is not installed in this venv ({venv_name()})"})
|
|
76
|
+
sys.exit(2)
|
|
77
|
+
|
|
78
|
+
cred_vars = _parse_json_arg(_opt(spec.cred_vars), "--cred-vars")
|
|
79
|
+
env_file = ""
|
|
80
|
+
if cred_vars:
|
|
81
|
+
if not isinstance(cred_vars, list):
|
|
82
|
+
_emit({"error": "--cred-vars must be a JSON array of variable names"})
|
|
83
|
+
sys.exit(2)
|
|
84
|
+
if not target.run_as:
|
|
85
|
+
_emit({"error": f"credentials require a run_as runnable, but '{runnable}' declares none"})
|
|
86
|
+
sys.exit(2)
|
|
87
|
+
env = capture_env([str(v) for v in cred_vars])
|
|
88
|
+
try:
|
|
89
|
+
env_file = write_env_file(sid, env, target.run_as, target.become_method, target.become_flags)
|
|
90
|
+
except (RuntimeError, ValueError) as exc:
|
|
91
|
+
_emit({"error": f"could not write credential env file: {exc}"})
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
entry = ScheduleEntry(
|
|
95
|
+
id=sid,
|
|
96
|
+
when=when,
|
|
97
|
+
runnable=runnable,
|
|
98
|
+
args=args,
|
|
99
|
+
enabled=bool(spec.enabled.value),
|
|
100
|
+
run_as=target.run_as,
|
|
101
|
+
become_method=target.become_method,
|
|
102
|
+
env_file=env_file,
|
|
103
|
+
)
|
|
104
|
+
upsert_schedule(path, entry)
|
|
105
|
+
_emit({"ok": True, "id": sid, "runnable": runnable, "run_as": target.run_as, "env_file": bool(env_file)})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cmd_remove(spec: rs.RunSpec) -> None:
|
|
109
|
+
path = _cfg_path(spec)
|
|
110
|
+
sid = str(spec.id.value)
|
|
111
|
+
existing = next((e for e in load_schedules(path) if e.id == sid), None)
|
|
112
|
+
removed = remove_schedule(path, sid)
|
|
113
|
+
if existing and existing.env_file:
|
|
114
|
+
remove_env_file(existing.env_file, existing.run_as, existing.become_method)
|
|
115
|
+
_emit({"ok": removed, "id": sid})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cmd_list(spec: rs.RunSpec) -> None:
|
|
119
|
+
path = _cfg_path(spec)
|
|
120
|
+
out = []
|
|
121
|
+
for entry in load_schedules(path):
|
|
122
|
+
record = entry.to_dict()
|
|
123
|
+
nxt = next_fire_time(entry.when) if entry.enabled else None
|
|
124
|
+
record["next_run"] = nxt.isoformat() if nxt else None
|
|
125
|
+
out.append(record)
|
|
126
|
+
_emit({"schedules": out, "venv": venv_name(), "config": str(path)})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cmd_run_now(spec: rs.RunSpec) -> None:
|
|
130
|
+
path = _cfg_path(spec)
|
|
131
|
+
sid = str(spec.id.value)
|
|
132
|
+
entry = next((e for e in load_schedules(path) if e.id == sid), None)
|
|
133
|
+
if entry is None:
|
|
134
|
+
_emit({"error": f"no schedule with id '{sid}'"})
|
|
135
|
+
sys.exit(2)
|
|
136
|
+
target = resolve_target(entry.runnable)
|
|
137
|
+
if target is None:
|
|
138
|
+
_emit({"error": f"runnable '{entry.runnable}' not found in this venv"})
|
|
139
|
+
sys.exit(2)
|
|
140
|
+
result = run_runnable(
|
|
141
|
+
target.binary,
|
|
142
|
+
[],
|
|
143
|
+
dict(entry.args),
|
|
144
|
+
source_toml=target.source,
|
|
145
|
+
run_as=entry.run_as or target.run_as,
|
|
146
|
+
become_method=entry.become_method or target.become_method,
|
|
147
|
+
become_flags=target.become_flags,
|
|
148
|
+
env_file=entry.env_file,
|
|
149
|
+
)
|
|
150
|
+
_emit(
|
|
151
|
+
{
|
|
152
|
+
"id": sid,
|
|
153
|
+
"runnable": entry.runnable,
|
|
154
|
+
"exit_code": result.returncode,
|
|
155
|
+
"stdout": (result.stdout or "").strip(),
|
|
156
|
+
"stderr": (result.stderr or "").strip(),
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
sys.exit(0 if result.returncode == 0 else 1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _cmd_install_service(spec: rs.RunSpec) -> None:
|
|
163
|
+
from .service import install_service
|
|
164
|
+
|
|
165
|
+
result = install_service(enable=bool(spec.enable.value))
|
|
166
|
+
_emit(result)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> None:
|
|
170
|
+
spec = rs.parse("runspec-scheduler")
|
|
171
|
+
command = spec.runspec_command
|
|
172
|
+
if command == "run":
|
|
173
|
+
_cmd_run(spec)
|
|
174
|
+
elif command == "add":
|
|
175
|
+
_cmd_add(spec)
|
|
176
|
+
elif command == "remove":
|
|
177
|
+
_cmd_remove(spec)
|
|
178
|
+
elif command == "list":
|
|
179
|
+
_cmd_list(spec)
|
|
180
|
+
elif command == "run-now":
|
|
181
|
+
_cmd_run_now(spec)
|
|
182
|
+
elif command == "install-service":
|
|
183
|
+
_cmd_install_service(spec)
|
|
184
|
+
else: # pragma: no cover - require-command makes this unreachable
|
|
185
|
+
_emit({"error": "no command — try: run, add, remove, list, run-now, install-service"})
|
|
186
|
+
sys.exit(2)
|