diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..2b94f98d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(python *)", + "Bash(DEVPLACE_DISABLE_SERVICES=1 python -)", + "Bash(command -v hawk)", + "Bash(export DEVPLACE_DISABLE_SERVICES=1)", + "Bash(export DEVPLACE_DATABASE_URL=\"sqlite:///tmp/devplace_verify.db\")", + "Bash(rm -f /tmp/devplace_verify.db)", + "Bash(export DEVPLACE_DATABASE_URL=\"sqlite:////tmp/devplace_verify.db\")", + "Bash", + "Edit(/home/retoor/projects/devplacepy/devplacepy/routers/projects/containers/instances.py)", + "Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/components/ContainerTerminal.js)", + "Edit(/home/retoor/projects/devplacepy/devplacepy/services/containers/store.py)", + "Verify", + "Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/MessagesLayout.js)", + "Write(/home/retoor/projects/devplacepy/devplacepy/static/css/messages.css)" + ] + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..3e6730f8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/scripts/check_database_imports.py b/scripts/check_database_imports.py new file mode 100644 index 00000000..eed8b377 --- /dev/null +++ b/scripts/check_database_imports.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +ALLOW_PREFIXES = ( + ROOT / "devplacepy" / "database", + ROOT / "devplacepy" / "db_client.py", + ROOT / "devplacepy_services" / "database", + ROOT / "scripts" / "migrate_db_imports.py", + ROOT / "scripts" / "check_database_imports.py", +) + +PATTERNS = [ + re.compile(r"from devplacepy\.database(?:\.[a-z_]+)? import"), + re.compile(r"import devplacepy\.database\b"), +] + +# Only flagged outside tests/: unit tests may legitimately use `local_db` / +# `from devplacepy import database` directly (Section 13.4). Source code must +# always go through `devplacepy.db_client` so the remote broker patch applies. +SOURCE_ONLY_PATTERNS = [ + re.compile(r"from devplacepy import(?:\s+\w+,)*\s+database\b"), +] + + +def allowed(path: Path) -> bool: + for prefix in ALLOW_PREFIXES: + if path == prefix or str(path).startswith(str(prefix) + "/"): + return True + return False + + +def main() -> int: + errors: list[str] = [] + for pattern in ("devplacepy/**/*.py", "devplacepy_services/**/*.py", "tests/**/*.py"): + is_source = not pattern.startswith("tests/") + for path in ROOT.glob(pattern): + if not path.is_file() or allowed(path): + continue + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + active_patterns = PATTERNS + SOURCE_ONLY_PATTERNS if is_source else PATTERNS + for lineno, line in enumerate(text.splitlines(), 1): + for rx in active_patterns: + if rx.search(line): + errors.append(f"{path.relative_to(ROOT)}:{lineno}: {line.strip()}") + break + if errors: + print("FORBIDDEN database imports (use devplacepy.db_client):") + for line in sorted(errors)[:80]: + print(f" {line}") + if len(errors) > 80: + print(f" ... and {len(errors) - 80} more") + return 1 + print("check_database_imports: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/check_no_monolith.py b/scripts/check_no_monolith.py new file mode 100644 index 00000000..5c662b63 --- /dev/null +++ b/scripts/check_no_monolith.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ALLOWLIST = { + ROOT / "splitup.md", + ROOT / "scripts/check_no_monolith.py", +} + +PATTERNS = [ + (r"uvicorn\s+devplacepy\.main:app", "uvicorn monolith boot"), + (r"from devplacepy\.main import", "main.py import"), + (r"import devplacepy\.main", "main.py import"), + (r"devplacepy\.main:app", "main app string"), + (r"DEVPLACE_DB_MODE", "hybrid DB mode flag"), + (r"make dev-monolith", "dev-monolith target"), + (r"make prod-monolith", "prod-monolith target"), + (r"DEVPLACE_TEST_PORT=10501", "legacy test port default"), + (r"monolith-dev", "monolith port profile"), +] + +PHASE = sys.argv[1] if len(sys.argv) > 1 else "m7" + + +def scan_file(path: Path) -> list[str]: + if path in ALLOWLIST: + return [] + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return [] + hits = [] + for pattern, label in PATTERNS: + if re.search(pattern, text): + hits.append(f"{path.relative_to(ROOT)}: {label}") + return hits + + +def main() -> int: + import subprocess + + db_check = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "check_database_imports.py")], + capture_output=True, + text=True, + check=False, + ) + if db_check.returncode != 0: + print(db_check.stdout or db_check.stderr) + return 1 + globs = [ + "Makefile", + "Dockerfile", + "docker-compose*.yml", + ".gitea/**/*.yaml", + "CLAUDE.md", + "README.md", + "pyproject.toml", + "devplacepy/**/*.py", + "tests/**/*.py", + "orchestrator.py", + ] + errors = [] + for pattern in globs: + for path in ROOT.glob(pattern): + if path.is_file(): + errors.extend(scan_file(path)) + if PHASE in ("m3", "m6", "m7") and (ROOT / "devplacepy/main.py").exists(): + if PHASE == "m7": + errors.append("devplacepy/main.py: file must be deleted at M7") + elif PHASE == "m3": + pass + if errors: + print("MONOLITH ARTIFACTS FOUND:") + for line in sorted(set(errors)): + print(f" {line}") + return 1 + print("check_no_monolith: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/migrate_db_imports.py b/scripts/migrate_db_imports.py new file mode 100644 index 00000000..826a8a7d --- /dev/null +++ b/scripts/migrate_db_imports.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKIP_PREFIXES = ( + ROOT / "devplacepy" / "database", + ROOT / "devplacepy_services" / "database", + ROOT / "devplacepy" / "db_client.py", + ROOT / "scripts", + ROOT / "splitup.md", +) + +REPLACEMENTS = [ + (re.compile(r"\bfrom devplacepy\.database import\b"), "from devplacepy.db_client import"), + (re.compile(r"\bfrom devplacepy\.database\.([a-z_]+) import\b"), r"from devplacepy.db_client import"), + (re.compile(r"\bimport devplacepy\.database\b"), "import devplacepy.db_client"), +] + + +def should_skip(path: Path) -> bool: + for prefix in SKIP_PREFIXES: + if path == prefix or str(path).startswith(str(prefix) + "/"): + return True + return False + + +def migrate_file(path: Path) -> bool: + text = path.read_text(encoding="utf-8") + original = text + for pattern, repl in REPLACEMENTS: + text = pattern.sub(repl, text) + if text != original: + path.write_text(text, encoding="utf-8") + return True + return False + + +def main() -> int: + changed = [] + for pattern in ("devplacepy/**/*.py", "tests/**/*.py", "devplacepy_services/**/*.py"): + for path in ROOT.glob(pattern): + if not path.is_file() or should_skip(path): + continue + if migrate_file(path): + changed.append(path.relative_to(ROOT)) + if changed: + print(f"migrated {len(changed)} files") + for item in sorted(changed)[:40]: + print(f" {item}") + if len(changed) > 40: + print(f" ... and {len(changed) - 40} more") + else: + print("no files changed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/refactor_to_microservices.py b/scripts/refactor_to_microservices.py new file mode 100644 index 00000000..5817a2f4 --- /dev/null +++ b/scripts/refactor_to_microservices.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +import argparse +import importlib +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SERVICES_DIR = ROOT / "devplacepy_services" + +SERVICES = [ + "web", + "database", + "pubsub", + "gateway", + "jobs", + "devii", + "bot", + "backup", + "containers", + "telegram", + "email", + "news", + "gitea", + "audit", + "xmlrpc", +] + + +def list_services() -> int: + for name in SERVICES: + main_py = SERVICES_DIR / name / "main.py" + status = "ok" if main_py.exists() else "missing" + print(f"{name:12} {status}") + return 0 + + +def verify_stubs() -> int: + sys.path.insert(0, str(ROOT)) + errors: list[str] = [] + for name in SERVICES: + main_py = SERVICES_DIR / name / "main.py" + if not main_py.exists(): + errors.append(f"{name}: missing main.py") + continue + module_name = f"devplacepy_services.{name}.main" + try: + module = importlib.import_module(module_name) + except Exception as exc: + errors.append(f"{name}: import failed ({exc})") + continue + app = getattr(module, "app", None) + if app is None: + errors.append(f"{name}: no app export") + continue + try: + from fastapi.testclient import TestClient + + client = TestClient(app) + resp = client.get("/health") + if resp.status_code != 200: + errors.append(f"{name}: /health returned {resp.status_code}") + continue + data = resp.json() + for key in ("service", "status", "uptime_s", "version", "deps", "stats"): + if key not in data: + errors.append(f"{name}: health missing key {key}") + except Exception as exc: + errors.append(f"{name}: health check failed ({exc})") + if errors: + print("verify: failed") + for err in errors: + print(f" {err}") + return 1 + print("verify: ok") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(prog="refactor_to_microservices.py") + parser.add_argument("--list", action="store_true") + parser.add_argument("--verify", action="store_true") + args = parser.parse_args() + if args.list: + return list_services() + if args.verify: + return verify_stubs() + parser.print_help() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file