"""Persistent state shared by the independent update services.""" from __future__ import annotations import fcntl import json import os from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterator CONFIG_FILE = Path('/etc/replypier-updater/config.json') BUSY = {'queued', 'preparing', 'building', 'backing_up', 'deploying', 'migrating', 'verifying', 'rolling_back'} def now() -> str: return datetime.now(timezone.utc).isoformat() def config() -> dict[str, Any]: return json.loads(CONFIG_FILE.read_text()) def directory(cfg: dict[str, Any]) -> Path: return Path(cfg['state_dir']) def read(cfg: dict[str, Any]) -> dict[str, Any]: p = directory(cfg) / 'status.json' return json.loads(p.read_text()) if p.exists() else { 'current': {'revision': cfg['initial_revision'], 'subject': '已安装版本'}, 'latest': None, 'checked_at': None, 'checking': False, 'check_error': None, 'update': {'status': 'idle', 'message': ''}, } def write(cfg: dict[str, Any], state: dict[str, Any]) -> None: target = directory(cfg) / 'status.json' temp = target.with_suffix('.tmp') with temp.open('w') as out: json.dump(state, out, ensure_ascii=False, indent=2) out.flush() os.fsync(out.fileno()) temp.chmod(0o644) temp.replace(target) @contextmanager def transaction(cfg: dict[str, Any]) -> Iterator[dict[str, Any]]: directory(cfg).mkdir(parents=True, exist_ok=True) with (directory(cfg) / 'state.lock').open('a') as lock: fcntl.flock(lock, fcntl.LOCK_EX) state = read(cfg) yield state write(cfg, state) def progress(cfg: dict[str, Any], status: str, message: str, **values: Any) -> None: with transaction(cfg) as state: state['update'].update(status=status, message=message, **values) def public_status(cfg: dict[str, Any]) -> dict[str, Any]: state = read(cfg) current = state['current']['revision'] latest = (state.get('latest') or {}).get('revision') return {**state, 'configured': True, 'can_manage': True, 'repository_url': cfg['web_url'], 'branch': cfg['branch'], 'check_interval_minutes': 30, 'update_available': bool(latest and latest != current and not state.get('check_error'))}