93 lines
3.0 KiB
Python
93 lines
3.0 KiB
Python
"""JSON "DB" — the only persistence layer (no real database, by design).
|
|
|
|
All mutable, user-editable state lives in <DATA_DIR>/config.json. Writes are
|
|
atomic (temp file + os.replace) and guarded by a cross-process file lock so the
|
|
scheduler thread and the web UI never corrupt the file when writing together.
|
|
"""
|
|
import json
|
|
import os
|
|
import threading
|
|
|
|
from filelock import FileLock
|
|
|
|
from . import settings
|
|
|
|
_LOCK = threading.RLock()
|
|
_FILE_LOCK = FileLock(settings.CONFIG_PATH + ".lock")
|
|
|
|
DEFAULT_CONFIG = {
|
|
"schedule": "0 3 * * *", # cron: daily at 03:00 (in `timezone`)
|
|
"schedule_enabled": True,
|
|
"timezone": "", # IANA tz, e.g. "Europe/Berlin"; "" -> TZ env/UTC
|
|
"retention_n": 7, # keep last N .tar per stack
|
|
"auto_discover": True, # auto-add newly seen stacks before a run
|
|
"dockhand_stacks_base": "", # host dir where Dockhand stores compose/env
|
|
"stacks": [], # [{name, order, enabled, extra_paths:[]}]
|
|
"custom_paths": [], # [{label, path, enabled}]
|
|
"history": [], # [{stack, ts, status, file, size, error}]
|
|
}
|
|
|
|
MAX_HISTORY = 500
|
|
|
|
|
|
def _ensure_dir():
|
|
os.makedirs(settings.DATA_DIR, exist_ok=True)
|
|
|
|
|
|
def load() -> dict:
|
|
"""Load config, filling in any missing keys with defaults."""
|
|
with _LOCK:
|
|
_ensure_dir()
|
|
if not os.path.exists(settings.CONFIG_PATH):
|
|
save(DEFAULT_CONFIG)
|
|
return json.loads(json.dumps(DEFAULT_CONFIG))
|
|
with open(settings.CONFIG_PATH, "r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
merged = json.loads(json.dumps(DEFAULT_CONFIG))
|
|
merged.update(data)
|
|
return merged
|
|
|
|
|
|
def save(config: dict) -> None:
|
|
"""Atomically persist the whole config."""
|
|
with _LOCK, _FILE_LOCK:
|
|
_ensure_dir()
|
|
tmp = settings.CONFIG_PATH + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
json.dump(config, fh, indent=2, ensure_ascii=False)
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
os.replace(tmp, settings.CONFIG_PATH)
|
|
|
|
|
|
def update(mutator) -> dict:
|
|
"""Load, apply mutator(config) in place, save, return the new config.
|
|
|
|
Holds the lock across the whole read-modify-write so concurrent updates
|
|
from the UI and scheduler don't clobber each other.
|
|
"""
|
|
with _LOCK, _FILE_LOCK:
|
|
config = load()
|
|
mutator(config)
|
|
save(config)
|
|
return config
|
|
|
|
|
|
def stacks_by_order(config: dict):
|
|
"""Stacks sorted by their `order` field."""
|
|
return sorted(config.get("stacks", []), key=lambda s: s.get("order", 0))
|
|
|
|
|
|
def get_stack(config: dict, name: str):
|
|
for s in config.get("stacks", []):
|
|
if s["name"] == name:
|
|
return s
|
|
return None
|
|
|
|
|
|
def add_history(config: dict, entry: dict) -> None:
|
|
"""Prepend a run entry, trimming to MAX_HISTORY. Mutates config in place."""
|
|
hist = config.setdefault("history", [])
|
|
hist.insert(0, entry)
|
|
del hist[MAX_HISTORY:]
|