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
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:
+23
-4
@@ -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.
|
- `/opt/credentials` read-only: named credential files used by those scripts.
|
||||||
- `/var/lib/server-maintenance`: persistent SQLite data, logs, and task state.
|
- `/var/lib/server-maintenance`: persistent SQLite data, logs, and task state.
|
||||||
|
|
||||||
Task processes receive a minimal environment. To forward non-secret container
|
Each group's durable task data lives under
|
||||||
settings to tasks, list their names in `SERVER_MAINTENANCE_TASK_ENV`, separated
|
`/var/lib/server-maintenance/tasks/<group-id>`. When a task group disappears
|
||||||
by commas. This allowlist prevents unrelated container configuration from
|
from the mounted task directory, its state is retained for 14 days and then
|
||||||
being exposed to every task; credentials should remain in `/opt/credentials`.
|
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
|
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
|
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
|
the input JSON and the files are removed when the task finishes, fails, or is
|
||||||
cancelled.
|
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
|
## Artifacts
|
||||||
|
|
||||||
Tasks can write files to the directory named by
|
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
|
`state`, `tasks`, and `credentials`; set
|
||||||
`SERVER_MAINTENANCE_ALLOWED_PROXY_IPS` to the reverse proxy's direct peer
|
`SERVER_MAINTENANCE_ALLOWED_PROXY_IPS` to the reverse proxy's direct peer
|
||||||
address. Keep the application behind an authenticated reverse proxy.
|
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.
|
||||||
|
|||||||
+51
-16
@@ -18,6 +18,10 @@ from .inputs import PendingUpload
|
|||||||
from .store import Run, RunStore
|
from .store import Run, RunStore
|
||||||
|
|
||||||
|
|
||||||
|
ORPHANED_TASK_STATE_FILENAME = ".orphaned-groups.json"
|
||||||
|
ORPHANED_TASK_STATE_RETENTION_SECONDS = 14 * 86400
|
||||||
|
|
||||||
|
|
||||||
class RunManager:
|
class RunManager:
|
||||||
def __init__(self, settings: Settings, catalog: TaskCatalog, store: RunStore) -> None:
|
def __init__(self, settings: Settings, catalog: TaskCatalog, store: RunStore) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -112,13 +116,17 @@ class RunManager:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def cleanup_transient_files(self, *, artifact_max_age_seconds: int = 86_400) -> None:
|
def cleanup_transient_files(
|
||||||
"""Remove uploads from interrupted runs and expired undownloaded artifacts."""
|
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"
|
runs_root = self.settings.state_root / "runs"
|
||||||
if not runs_root.is_dir():
|
|
||||||
return
|
|
||||||
cutoff = time.time() - artifact_max_age_seconds
|
cutoff = time.time() - artifact_max_age_seconds
|
||||||
|
if runs_root.is_dir():
|
||||||
for path in runs_root.iterdir():
|
for path in runs_root.iterdir():
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -142,6 +150,44 @@ class RunManager:
|
|||||||
run = self.store.get(int(run_id_text))
|
run = self.store.get(int(run_id_text))
|
||||||
if run is None or run.status not in {"queued", "running"}:
|
if run is None or run.status not in {"queued", "running"}:
|
||||||
path.unlink(missing_ok=True)
|
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
|
||||||
|
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:
|
def run_background_task(self, task: BackgroundTask) -> None:
|
||||||
"""Run a declared internal task without creating high-volume run history."""
|
"""Run a declared internal task without creating high-volume run history."""
|
||||||
@@ -335,22 +381,11 @@ class RunManager:
|
|||||||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||||||
"PYTHONUNBUFFERED": "1",
|
"PYTHONUNBUFFERED": "1",
|
||||||
"SERVER_MAINTENANCE_CREDENTIALS": str(self.settings.credentials_root),
|
"SERVER_MAINTENANCE_CREDENTIALS": str(self.settings.credentials_root),
|
||||||
|
"SERVER_MAINTENANCE_STATE_ROOT": str(self.settings.state_root),
|
||||||
"SERVER_MAINTENANCE_STATE": str(task_state),
|
"SERVER_MAINTENANCE_STATE": str(task_state),
|
||||||
}
|
}
|
||||||
if self.settings.timezone_name:
|
if self.settings.timezone_name:
|
||||||
environment["TZ"] = 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
|
return environment
|
||||||
|
|
||||||
def _upload_root(self, run_id: int) -> Path:
|
def _upload_root(self, run_id: int) -> Path:
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ services:
|
|||||||
- "${PYTHON_TOOLS_PORT:-8080}:8080"
|
- "${PYTHON_TOOLS_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
SERVER_MAINTENANCE_ALLOWED_PROXY_IPS: "${SERVER_MAINTENANCE_ALLOWED_PROXY_IPS:?Set the reverse proxy's direct peer address}"
|
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:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- ${PYTHON_TOOLS_VOLUME_ROOT:?Set the persistent volume root}/state:/var/lib/server-maintenance
|
- ${PYTHON_TOOLS_VOLUME_ROOT:?Set the persistent volume root}/state:/var/lib/server-maintenance
|
||||||
|
|||||||
@@ -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:
|
def test_group_stops_after_failed_task(manager: RunManager) -> None:
|
||||||
root = manager.settings.task_root
|
root = manager.settings.task_root
|
||||||
write_task(root, "workflow", "01_ok.py", '"""Works."""\nprint("ok")\n')
|
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"}'
|
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,
|
manager: RunManager,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setenv("SERVER_MAINTENANCE_TASK_ENV", "TASK_SETTING")
|
|
||||||
monkeypatch.setenv("TASK_SETTING", "available")
|
monkeypatch.setenv("TASK_SETTING", "available")
|
||||||
monkeypatch.setenv("UNRELATED_SETTING", "hidden")
|
monkeypatch.setenv("UNRELATED_SETTING", "hidden")
|
||||||
root = manager.settings.task_root
|
root = manager.settings.task_root
|
||||||
@@ -182,7 +194,7 @@ def test_task_receives_only_explicitly_forwarded_environment(
|
|||||||
root,
|
root,
|
||||||
"environment",
|
"environment",
|
||||||
"read_environment.py",
|
"read_environment.py",
|
||||||
'''"""Reads forwarded configuration."""
|
'''"""Reads the task environment."""
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -198,7 +210,32 @@ state = Path(os.environ["SERVER_MAINTENANCE_STATE"])
|
|||||||
|
|
||||||
assert wait_for(manager, run.id, {"succeeded"}) == "succeeded"
|
assert wait_for(manager, run.id, {"succeeded"}) == "succeeded"
|
||||||
assert (manager.settings.state_root / "tasks" / "environment" / "environment.txt").read_text() == (
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user