from __future__ import annotations import json import os from pathlib import Path from datetime import datetime from app.core.config import settings def domain_root() -> Path: return Path(settings.domain_root) def resolve_domain_path(*relative_paths: str) -> Path | None: root = domain_root() candidates: list[Path] = [] seen: set[str] = set() for relative_path in relative_paths: text = str(relative_path or "").strip() if not text: continue candidate = root / text candidate_key = str(candidate) if candidate_key not in seen: seen.add(candidate_key) candidates.append(candidate) if "/" not in text and "\\" not in text: nested_candidate = root / "logs" / text nested_key = str(nested_candidate) if nested_key not in seen: seen.add(nested_key) candidates.append(nested_candidate) for candidate in candidates: if candidate.exists(): return candidate return candidates[0] if candidates else None def read_json(relative_path: str, default: dict | list | None = None): path = domain_root() / relative_path if not path.exists(): return {} if default is None else default try: with path.open("r", encoding="utf-8") as handle: return json.load(handle) except (json.JSONDecodeError, OSError): return {} if default is None else default def write_json(relative_path: str, payload) -> None: path = domain_root() / relative_path path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) def tail_lines(relative_path: str, max_lines: int = 120) -> list[str]: path = resolve_domain_path(relative_path) if path is None or not path.exists(): return [] with path.open("r", encoding="utf-8", errors="replace") as handle: return handle.read().splitlines()[-max_lines:] def runtime_root() -> Path: env_override = str(os.getenv("DOMAIN_API_RUNTIME_ROOT", "") or "").strip() candidates: list[Path] = [] if env_override: candidates.append(Path(env_override)) base_dir = Path(__file__).resolve().parents[2] for parent in base_dir.parents: if parent.name != "releases": continue # Released builds live under /opt/domaincheck/releases//domain-api. # Runtime state must not be written back into the immutable release tree, # otherwise sync-agent / detect runtime snapshots fail with permission errors. candidates.append(parent.parent / "runtime" / "domain-api") break candidates.append(base_dir / "runtime") last_error: OSError | None = None for candidate in candidates: try: candidate.mkdir(parents=True, exist_ok=True) return candidate except OSError as exc: last_error = exc continue if last_error is not None: raise last_error raise RuntimeError("failed to resolve runtime root") def read_runtime_json(filename: str, default: dict | list | None = None): path = runtime_root() / filename if not path.exists(): return {} if default is None else default try: with path.open("r", encoding="utf-8") as handle: return json.load(handle) except (json.JSONDecodeError, OSError): return {} if default is None else default def write_runtime_json(filename: str, payload) -> None: path = runtime_root() / filename path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2, default=str) def exports_root() -> Path: path = runtime_root() / "exports" path.mkdir(parents=True, exist_ok=True) return path def import_root() -> Path: path = runtime_root() / "imports" path.mkdir(parents=True, exist_ok=True) return path def settings_backup_root() -> Path: path = runtime_root() / "settings_backups" path.mkdir(parents=True, exist_ok=True) return path def load_export_records() -> list[dict]: path = runtime_root() / "export_tasks.json" if not path.exists(): return [] try: with path.open("r", encoding="utf-8") as handle: return json.load(handle) except (json.JSONDecodeError, OSError): return [] def save_export_record(record: dict) -> None: records = load_export_records() records.insert(0, record) path = runtime_root() / "export_tasks.json" with path.open("w", encoding="utf-8") as handle: json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str) def load_import_records() -> list[dict]: path = runtime_root() / "import_tasks.json" if not path.exists(): return [] try: with path.open("r", encoding="utf-8") as handle: return json.load(handle) except (json.JSONDecodeError, OSError): return [] def save_import_records(records: list[dict]) -> None: path = runtime_root() / "import_tasks.json" with path.open("w", encoding="utf-8") as handle: json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str) def load_juming_records() -> list[dict]: path = runtime_root() / "juming_tasks.json" if not path.exists(): return [] try: with path.open("r", encoding="utf-8") as handle: return json.load(handle) except (json.JSONDecodeError, OSError): return [] def save_juming_records(records: list[dict]) -> None: path = runtime_root() / "juming_tasks.json" with path.open("w", encoding="utf-8") as handle: json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str) def load_detect_records() -> list[dict]: path = runtime_root() / "detect_runs.json" if not path.exists(): return [] try: with path.open("r", encoding="utf-8") as handle: return json.load(handle) except (json.JSONDecodeError, UnicodeDecodeError, OSError): try: raw = path.read_bytes() text = raw.decode("utf-8", errors="replace") decoder = json.JSONDecoder() payload, _ = decoder.raw_decode(text) if isinstance(payload, list): return payload except Exception: pass return [] def save_detect_records(records: list[dict]) -> None: path = runtime_root() / "detect_runs.json" with path.open("w", encoding="utf-8") as handle: json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str) def timestamp_filename(prefix: str, ext: str) -> str: return f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}"