6 Commits
Author SHA1 Message Date
Juan Denis 83ea2c59d4 Write cron and storage settings atomically
Both files used a truncating open() and their loaders read damage as
empty, so a full disk mid-write reset off-site backups to local and
the next cron add replaced every job's metadata with the new job.
Route both through the config store's temp+replace write; an
unreadable file is moved aside instead of being overwritten.
2026-10-26 00:53:25 -04:00
Juan Denis 6296887bad Remove internal plan citations from app code
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
2026-10-10 15:50:45 -04:00
Juan Denis e2ba3a3476 Keep corrupt configs and serialize config writes
A corrupt JSON config read as the defaults and the next save, which
writes back what was loaded, destroyed the only copy of the operator's
settings; it is now moved aside first. Every save also used the same
fixed temp name, so two concurrent writers could rename a half-written
file into place; each write now gets its own temp file and writers to
one path are serialized.
2026-10-10 14:04:09 -04:00
Juan DenisandClaude Opus 5 b768fef728 refactor(db): one SQL-exec dispatcher, and adopt the doors already built
Plan 75 §G5 + §G6.

§G5 — app/services/db_exec.py. engine × host dispatch (mysql/postgres
crossed with native/docker) was written three times. Only
managed_db_user_service delegated fully to DatabaseService's executors;
the other two grew private copies that had already drifted —
db_process_service's docker-postgres branch built its own psql argv and
never set ON_ERROR_STOP=1, so psql exited 0 for a statement the server
had rejected and the caller was told it worked.

db_exec owns the dispatch and nothing else; the four executors stay in
DatabaseService. The three _exec_sql seams remain (tests stub them) and
now delegate. An unrecognised engine is reported, never quietly routed to
mysql, where it would surface as a syntax error blamed on the caller.

docker_mysql_execute/docker_pg_execute gain machine_readable, which is
the single flag db_config_tuner_service was keeping a whole private copy
of the dispatcher to add. The MYSQL_PWD env block, pasted six times in
database_service, becomes _mysql_env(): it carries a real rule — the
password goes in the environment, never on the argv — and a rule in six
copies only holds in the copies someone remembered.

§G6 — adopting doors Round 1 built:

- config_store's write is now atomic (tmp + os.replace). The §F4
  consolidation deliberately kept the copies' truncating write and noted
  atomicity would land here once; this is that once. A write that dies
  mid-dump leaves a truncated file, and every load path treats a corrupt
  config as "use the default" — a config silently reset.
- uptime_service._save_history was `except Exception: pass` over that
  truncating write, so a full disk destroyed the history and reported
  nothing while the panel kept rendering a figure from a file that had
  stopped updating. It now returns and logs the failure.
- unit_is_active(): `systemctl is-active` was re-inlined in four services
  and every copy collapsed "could not ask" into "not running". The shared
  probe is tri-state; None is not False.
- slug: preview_service and saved_view_service re-implemented slugify's
  regex, and saved_view re-implemented unique_slug's collision loop too.
  unique_slug gains start= so saved_view keeps generating the handles it
  always has.

Checked rather than assumed: the plan lists file_integrity_service as
silently losing data on a full disk. It does not — it already used tmp +
os.replace and does not swallow the error. It is the pattern followed
here, not a victim; only uptime_service had the silent loss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:26:40 -04:00
Juan DenisandClaude Opus 5 ea7129b4d7 fix(utils): load_json_config hands back a copy, not the caller default
`return default` gave every caller the same object the caller passed in, and
callers do mutate what they get back (git_service, build_service and
monitoring_service all assign into it). No caller is actually bitten today --
all six construct a fresh literal at the call site -- so this hardens the
shared helper rather than repairing a live leak. That matters because
config_store is the one door the remaining hand-rolled copies are meant to
converge on, and the next adopter is the one that would pass a module-level
default.

Deep, not shallow: several defaults nest mutable dicts (backup_service's
notifications, security_service's scheduled_scan) that a shallow copy would
still alias. The test mutates through both a top-level list and a nested dict
for that reason -- it fails against `return default` AND against
`copy.copy(default)`.

Reported by Copilot on the plan-75 PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:56:47 -04:00
Juan Denis a3fe9bf59d refactor(services): one ConfigStore for the JSON-file config mechanics
Five services shipped byte-identical save_config copies and as many the
same get_config skeleton — the next fix (an atomic write, a better
error) would have had to land in every one. load_json_config /
save_json_config own the mechanics; the path, the default, and any
behaviour around the write stay per-service.

Byte-compatible with the copies: plain open(..., 'w'), no locking, no
atomic rename — adding those here would be a behaviour change smuggled
in with a consolidation; if they're ever wanted they now land once.
Divergent services (template cache invalidation, storage secret
merging, email_relay's DB backing, build_service's leaner return dict)
keep their own methods. monitoring_service converts separately — it is
mid-edit on another branch of this plan.
2026-08-17 22:14:18 -04:00