Simplify python-tools task configuration
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 42s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 53s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 1m18s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 26s

This commit is contained in:
ajp_anton
2026-08-24 15:40:31 +00:00
parent 8ec3cde4f2
commit cb80d420c0
4 changed files with 135 additions and 47 deletions
+23 -4
View File
@@ -18,10 +18,17 @@ The image contains application code only. Mount these paths at runtime:
- `/opt/credentials` read-only: named credential files used by those scripts.
- `/var/lib/server-maintenance`: persistent SQLite data, logs, and task state.
Task processes receive a minimal environment. To forward non-secret container
settings to tasks, list their names in `SERVER_MAINTENANCE_TASK_ENV`, separated
by commas. This allowlist prevents unrelated container configuration from
being exposed to every task; credentials should remain in `/opt/credentials`.
Each group's durable task data lives under
`/var/lib/server-maintenance/tasks/<group-id>`. When a task group disappears
from the mounted task directory, its state is retained for 14 days and then
removed automatically. Tasks that need more focused retention should clean up
their own state within that group.
Task processes receive only runner-provided paths for input, artifacts,
credentials, and state. Keep task-specific non-secret configuration in an
optional `task-config.json` beside that group's scripts, and credentials in
`/opt/credentials`. The runner does not interpret `task-config.json`; its
schema belongs to the task group that reads it.
Tasks are never imported during discovery. Their optional description is the
first line of their module docstring. A task must not require stdin or command
@@ -122,6 +129,12 @@ are stored in a private per-run directory; their absolute paths are provided in
the input JSON and the files are removed when the task finishes, fails, or is
cancelled.
A group may also include a private `task-config.json` for stable local details
such as mounted filesystem roots. This is not part of discovery or the web
form schema: task code reads and validates it itself. Keeping it beside the
group makes a task's runtime requirements clear and avoids a global
task-environment allowlist.
## Artifacts
Tasks can write files to the directory named by
@@ -154,3 +167,9 @@ stack. Set `PYTHON_TOOLS_VOLUME_ROOT` to the host directory that will hold
`state`, `tasks`, and `credentials`; set
`SERVER_MAINTENANCE_ALLOWED_PROXY_IPS` to the reverse proxy's direct peer
address. Keep the application behind an authenticated reverse proxy.
Tasks receive a group-specific writable directory in
`SERVER_MAINTENANCE_STATE`. Tasks which intentionally maintain application-wide
state, such as run-history maintenance, can use
`SERVER_MAINTENANCE_STATE_ROOT`; normal task state should remain
group-specific.
+71 -36
View File
@@ -18,6 +18,10 @@ from .inputs import PendingUpload
from .store import Run, RunStore
ORPHANED_TASK_STATE_FILENAME = ".orphaned-groups.json"
ORPHANED_TASK_STATE_RETENTION_SECONDS = 14 * 86400
class RunManager:
def __init__(self, settings: Settings, catalog: TaskCatalog, store: RunStore) -> None:
self.settings = settings
@@ -112,36 +116,78 @@ class RunManager:
except OSError:
pass
def cleanup_transient_files(self, *, artifact_max_age_seconds: int = 86_400) -> None:
"""Remove uploads from interrupted runs and expired undownloaded artifacts."""
def cleanup_transient_files(
self,
*,
artifact_max_age_seconds: int = 86_400,
orphaned_task_state_retention_seconds: int = ORPHANED_TASK_STATE_RETENTION_SECONDS,
) -> None:
"""Remove transient run data and state for task groups removed long ago."""
runs_root = self.settings.state_root / "runs"
if not runs_root.is_dir():
return
cutoff = time.time() - artifact_max_age_seconds
for path in runs_root.iterdir():
if not path.is_dir():
continue
run_id_text, separator, kind = path.name.partition(".")
if not separator or not run_id_text.isdigit() or kind not in {"uploads", "artifacts"}:
continue
run = self.store.get(int(run_id_text))
if kind == "uploads" and (run is None or run.status not in {"queued", "running"}):
shutil.rmtree(path, ignore_errors=True)
elif kind == "artifacts":
try:
expired = path.stat().st_mtime < cutoff
except FileNotFoundError:
if runs_root.is_dir():
for path in runs_root.iterdir():
if not path.is_dir():
continue
if expired:
run_id_text, separator, kind = path.name.partition(".")
if not separator or not run_id_text.isdigit() or kind not in {"uploads", "artifacts"}:
continue
run = self.store.get(int(run_id_text))
if kind == "uploads" and (run is None or run.status not in {"queued", "running"}):
shutil.rmtree(path, ignore_errors=True)
for path in runs_root.glob("*.execution-input.json"):
run_id_text = path.name.removesuffix(".execution-input.json")
if not run_id_text.isdigit():
elif kind == "artifacts":
try:
expired = path.stat().st_mtime < cutoff
except FileNotFoundError:
continue
if expired:
shutil.rmtree(path, ignore_errors=True)
for path in runs_root.glob("*.execution-input.json"):
run_id_text = path.name.removesuffix(".execution-input.json")
if not run_id_text.isdigit():
continue
run = self.store.get(int(run_id_text))
if run is None or run.status not in {"queued", "running"}:
path.unlink(missing_ok=True)
self._cleanup_orphaned_task_state(orphaned_task_state_retention_seconds)
def _cleanup_orphaned_task_state(self, retention_seconds: int) -> None:
"""Delete state only after a group remains absent for a grace period."""
if retention_seconds < 0 or not self.settings.task_root.is_dir():
return
root = self.settings.state_root / "tasks"
if not root.is_dir():
return
marker_path = root / ORPHANED_TASK_STATE_FILENAME
try:
markers = json.loads(marker_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
markers = {}
if not isinstance(markers, dict):
markers = {}
active_groups = {group.id for group in self.catalog.groups()}
current_time = time.time()
updated: dict[str, float] = {}
for path in root.iterdir():
if path.is_symlink() or not path.is_dir():
continue
run = self.store.get(int(run_id_text))
if run is None or run.status not in {"queued", "running"}:
path.unlink(missing_ok=True)
if path.name in active_groups:
continue
first_missing_at = markers.get(path.name, current_time)
if not isinstance(first_missing_at, (int, float)):
first_missing_at = current_time
if current_time - float(first_missing_at) >= retention_seconds:
shutil.rmtree(path, ignore_errors=True)
else:
updated[path.name] = float(first_missing_at)
if updated:
temporary = marker_path.with_suffix(marker_path.suffix + ".tmp")
temporary.write_text(json.dumps(updated, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(marker_path)
else:
marker_path.unlink(missing_ok=True)
def run_background_task(self, task: BackgroundTask) -> None:
"""Run a declared internal task without creating high-volume run history."""
@@ -335,22 +381,11 @@ class RunManager:
"PATH": "/usr/local/bin:/usr/bin:/bin",
"PYTHONUNBUFFERED": "1",
"SERVER_MAINTENANCE_CREDENTIALS": str(self.settings.credentials_root),
"SERVER_MAINTENANCE_STATE_ROOT": str(self.settings.state_root),
"SERVER_MAINTENANCE_STATE": str(task_state),
}
if self.settings.timezone_name:
environment["TZ"] = self.settings.timezone_name
for name in os.environ.get("SERVER_MAINTENANCE_TASK_ENV", "").split(","):
name = name.strip()
if not name:
continue
if not name.isidentifier() or name.startswith("SERVER_MAINTENANCE_"):
raise ValueError(
"SERVER_MAINTENANCE_TASK_ENV entries must be environment variable names "
"outside the SERVER_MAINTENANCE_ namespace"
)
value = os.environ.get(name)
if value is not None:
environment[name] = value
return environment
def _upload_root(self, run_id: int) -> Path:
-3
View File
@@ -7,9 +7,6 @@ services:
- "${PYTHON_TOOLS_PORT:-8080}:8080"
environment:
SERVER_MAINTENANCE_ALLOWED_PROXY_IPS: "${SERVER_MAINTENANCE_ALLOWED_PROXY_IPS:?Set the reverse proxy's direct peer address}"
# Optional task settings. Only names listed here are forwarded to task
# processes; keep credentials in the mounted credentials directory.
# SERVER_MAINTENANCE_TASK_ENV: TASK_SETTING_A,TASK_SETTING_B
volumes:
- /etc/localtime:/etc/localtime:ro
- ${PYTHON_TOOLS_VOLUME_ROOT:?Set the persistent volume root}/state:/var/lib/server-maintenance
+41 -4
View File
@@ -77,6 +77,19 @@ def test_tasks_in_one_group_are_serialized_and_groups_overlap(manager: RunManage
]
def test_cleanup_removes_state_for_a_removed_group_after_a_grace_period(manager: RunManager) -> None:
write_task(manager.settings.task_root, "active", "task.py", 'print("active")\n')
removed = manager.settings.state_root / "tasks" / "removed"
removed.mkdir(parents=True)
(removed / "state.json").write_text("{}\n", encoding="utf-8")
manager.cleanup_transient_files(orphaned_task_state_retention_seconds=60)
assert removed.is_dir()
manager.cleanup_transient_files(orphaned_task_state_retention_seconds=0)
assert not removed.exists()
def test_group_stops_after_failed_task(manager: RunManager) -> None:
root = manager.settings.task_root
write_task(root, "workflow", "01_ok.py", '"""Works."""\nprint("ok")\n')
@@ -170,11 +183,10 @@ data = json.loads(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
assert stored.input_json == '{"attempts":2,"identifier":"AB123"}'
def test_task_receives_only_explicitly_forwarded_environment(
def test_task_does_not_receive_parent_environment(
manager: RunManager,
monkeypatch,
) -> None:
monkeypatch.setenv("SERVER_MAINTENANCE_TASK_ENV", "TASK_SETTING")
monkeypatch.setenv("TASK_SETTING", "available")
monkeypatch.setenv("UNRELATED_SETTING", "hidden")
root = manager.settings.task_root
@@ -182,7 +194,7 @@ def test_task_receives_only_explicitly_forwarded_environment(
root,
"environment",
"read_environment.py",
'''"""Reads forwarded configuration."""
'''"""Reads the task environment."""
import os
from pathlib import Path
@@ -198,7 +210,32 @@ state = Path(os.environ["SERVER_MAINTENANCE_STATE"])
assert wait_for(manager, run.id, {"succeeded"}) == "succeeded"
assert (manager.settings.state_root / "tasks" / "environment" / "environment.txt").read_text() == (
"available:"
":"
)
def test_task_receives_application_state_root(manager: RunManager) -> None:
root = manager.settings.task_root
write_task(
root,
"environment",
"read_state_root.py",
'''"""Reads the application state root."""
import os
from pathlib import Path
Path(os.environ["SERVER_MAINTENANCE_STATE"]).joinpath("state-root.txt").write_text(
os.environ["SERVER_MAINTENANCE_STATE_ROOT"])
''',
)
task = TaskCatalog(root).task("environment/read_state_root.py")
assert task is not None
run, _ = manager.run_task(task)
assert wait_for(manager, run.id, {"succeeded"}) == "succeeded"
assert (manager.settings.state_root / "tasks" / "environment" / "state-root.txt").read_text() == str(
manager.settings.state_root
)