mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 15:47:50 +09:00
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.
113 lines
4.4 KiB
Python
113 lines
4.4 KiB
Python
"""JSON-file config persistence — one door for the load-or-default /
|
|
dump-or-error mechanics.
|
|
|
|
Five services shipped byte-identical ``save_config`` copies and seven more the
|
|
same ``get_config`` skeleton, each a place a fix (an atomic write, a better
|
|
error) would have had to land N times. These two helpers own the mechanics;
|
|
the per-service parts stay per-service: the path, the default, and any extra
|
|
behaviour around the write (cache invalidation, secret merging — those keep
|
|
their own methods and may call these helpers inside).
|
|
|
|
The write is atomic. The consolidation that created this module
|
|
deliberately kept the copies' plain ``open(..., 'w')`` so that no behaviour
|
|
change rode along with it, and noted that atomic writes, if ever wanted, would
|
|
land here once. This is that once: a truncating write that dies mid-``json.dump``
|
|
— a full disk, a killed process — leaves a truncated file where a config used
|
|
to be, and every caller's load path treats a corrupt config as "use the
|
|
default". That is a config silently reset to defaults, which is precisely the
|
|
class of false fact this plan exists to remove.
|
|
|
|
``file_integrity_service._save_state`` already used tmp + ``os.replace`` and is
|
|
the pattern followed here.
|
|
"""
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# One writer per file at a time within the (single) panel process.
|
|
_write_locks = {}
|
|
_write_locks_guard = threading.Lock()
|
|
|
|
|
|
def _lock_for(path):
|
|
key = os.path.abspath(path)
|
|
with _write_locks_guard:
|
|
return _write_locks.setdefault(key, threading.Lock())
|
|
|
|
|
|
def load_json_config(path, default):
|
|
"""JSON file → dict; *default* on missing or corrupt.
|
|
|
|
A corrupt config reads as the default, never raises — the same contract
|
|
the hand-rolled copies had, and the one callers rely on at boot. It is
|
|
moved aside first: callers save what they loaded, so the next save would
|
|
otherwise overwrite the only copy of the operator's settings with the
|
|
defaults. The fallback is a deep copy: callers mutate what they get back,
|
|
and a shared default object would leak those mutations into every later
|
|
call.
|
|
"""
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path, 'r') as f:
|
|
return json.load(f)
|
|
except Exception: # noqa: BLE001
|
|
quarantine(path)
|
|
return copy.deepcopy(default)
|
|
|
|
|
|
def quarantine(path):
|
|
"""Move an unreadable config aside so the next save can't overwrite it."""
|
|
aside = f'{path}.corrupt-{time.strftime("%Y%m%d%H%M%S")}'
|
|
try:
|
|
os.replace(path, aside)
|
|
logger.error('Unreadable config %s moved to %s; using defaults', path, aside)
|
|
except OSError:
|
|
logger.exception('Unreadable config %s could not be moved aside', path)
|
|
|
|
|
|
def save_json_config(path, config):
|
|
"""dict → JSON file (indented), creating the parent dir. Returns the
|
|
service-dict shape every caller already returns.
|
|
|
|
Written to a sibling temp file and ``os.replace``d into position, so a
|
|
reader never sees a half-written config and a failed write leaves the
|
|
previous one intact. ``os.replace`` is atomic on POSIX and on Windows.
|
|
"""
|
|
with _lock_for(path):
|
|
return _save(path, config)
|
|
|
|
|
|
def _save(path, config):
|
|
directory = os.path.dirname(path) or '.'
|
|
tmp = None
|
|
try:
|
|
os.makedirs(directory, exist_ok=True)
|
|
# A unique temp per write: with one fixed name, two concurrent saves
|
|
# wrote into the same file and could rename a half-written config
|
|
# into place.
|
|
fd, tmp = tempfile.mkstemp(dir=directory, prefix=os.path.basename(path) + '.',
|
|
suffix='.tmp')
|
|
with os.fdopen(fd, 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
if os.name != 'nt':
|
|
# mkstemp creates 0600; keep the mode the file had (or the
|
|
# 0644 a plain open() gave it) so other readers are unaffected.
|
|
os.chmod(tmp, os.stat(path).st_mode & 0o777 if os.path.exists(path) else 0o644)
|
|
os.replace(tmp, path)
|
|
return {'success': True, 'message': 'Configuration saved'}
|
|
except Exception as e: # noqa: BLE001
|
|
# The old file is still whole; drop the partial temp so a later write
|
|
# is not confused by it. Cleanup failure must not mask the real error.
|
|
if tmp:
|
|
try:
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass
|
|
return {'success': False, 'error': str(e)}
|