diff --git a/docs/engineering/INSTALLATION_RELOCATION.md b/docs/engineering/INSTALLATION_RELOCATION.md
new file mode 100644
index 00000000..81d4bf00
--- /dev/null
+++ b/docs/engineering/INSTALLATION_RELOCATION.md
@@ -0,0 +1,33 @@
+# Verplaatsen van installatie-opslag
+
+De Operations Console kan de platformdatabase en de File Inbox naar een door de
+operator gekozen lokale map verplaatsen. Deze actie is uitsluitend
+installatiebreed; een geselecteerd project heeft geen invloed op de bestemming.
+
+## Veilig verloop
+
+1. De Console controleert dat de gekozen map lokaal, absoluut en beschrijfbaar
+ is. Voor de File Inbox moet de inbox leeg zijn; anders blijft de actie
+ geblokkeerd.
+2. De Server schrijft een beperkte, atomische verhuisopdracht naar zijn eigen
+ runtime-map en antwoordt aan de Console.
+3. Daarna stopt de LaunchAgent de Server. Daarmee stoppen de lifecycle-worker,
+ inboxverwerking en andere schrijvende onderdelen voordat bestanden worden
+ gewijzigd.
+4. Bij de volgende start voert de Server de opdracht uit, vóór hij workers
+ start. Een database wordt met SQLite's backup-API gekopieerd en met
+ `integrity_check` gevalideerd. De vertrouwde installatiepaden worden daarna
+ atomair omgezet naar een lokale verwijzing naar de gekozen bestemming.
+5. De succesvolle wijziging wordt als configuratie-auditgebeurtenis in de
+ platformdatabase vastgelegd. De Console laadt vervolgens de nieuwe locatie.
+
+Een crash of herstart tussen stap 2 en 4 verliest de opdracht niet: de opdracht
+blijft staan en wordt op een volgende schone start opnieuw veilig verwerkt.
+
+## Herstel
+
+Als de gekozen bestemming tijdens de herstart niet meer bruikbaar is, blijft de
+opdracht staan en wordt er niet stilzwijgend naar een andere locatie
+geschreven. Herstel de rechten of bestemming en herstart de Engineering
+Platform Server. Gebruik geen handmatige bestandsverplaatsing terwijl de
+Server draait.
diff --git a/src/engineering_platform/assets/dashboard_locales.mjs b/src/engineering_platform/assets/dashboard_locales.mjs
index cea31032..7f390261 100644
--- a/src/engineering_platform/assets/dashboard_locales.mjs
+++ b/src/engineering_platform/assets/dashboard_locales.mjs
@@ -4015,3 +4015,8 @@ Object.assign(DASHBOARD_MESSAGES.nl, {"copy.path_success":"Pad gekopieerd naar k
Object.assign(DASHBOARD_MESSAGES.de, {"copy.path_success":"Pfad in die Zwischenablage kopiert"});
Object.assign(DASHBOARD_MESSAGES.fr, {"copy.path_success":"Chemin copié dans le presse-papiers"});
Object.assign(DASHBOARD_MESSAGES.es, {"copy.path_success":"Ruta copiada al portapapeles"});
+Object.assign(DASHBOARD_MESSAGES.en, {"configuration.relocate_database":"Relocate database","configuration.relocate_database_help":"Choose a writable local folder. The server stops safely first and then restarts.","configuration.choose_folder":"Choose folder","configuration.relocate":"Relocate","configuration.relocation_restarting":"Relocation is scheduled; the server is restarting.","configuration.relocation_failed":"Relocation could not be scheduled."});
+Object.assign(DASHBOARD_MESSAGES.nl, {"configuration.relocate_database":"Verplaats database","configuration.relocate_database_help":"Kies een beschrijfbare lokale map. De server stopt eerst veilig en start daarna opnieuw.","configuration.choose_folder":"Kies map","configuration.relocate":"Verplaatsen","configuration.relocation_restarting":"Verplaatsing staat klaar; de server start opnieuw.","configuration.relocation_failed":"De verplaatsing kon niet worden ingepland."});
+Object.assign(DASHBOARD_MESSAGES.de, {"configuration.relocate_database":"Datenbank verschieben","configuration.relocate_database_help":"Wählen Sie einen beschreibbaren lokalen Ordner. Der Server wird sicher beendet und dann neu gestartet.","configuration.choose_folder":"Ordner auswählen","configuration.relocate":"Verschieben","configuration.relocation_restarting":"Verschiebung ist geplant; der Server startet neu.","configuration.relocation_failed":"Die Verschiebung konnte nicht geplant werden."});
+Object.assign(DASHBOARD_MESSAGES.fr, {"configuration.relocate_database":"Déplacer la base de données","configuration.relocate_database_help":"Choisissez un dossier local accessible en écriture. Le serveur s’arrête en sécurité puis redémarre.","configuration.choose_folder":"Choisir le dossier","configuration.relocate":"Déplacer","configuration.relocation_restarting":"Le déplacement est programmé ; le serveur redémarre.","configuration.relocation_failed":"Le déplacement n’a pas pu être programmé."});
+Object.assign(DASHBOARD_MESSAGES.es, {"configuration.relocate_database":"Reubicar base de datos","configuration.relocate_database_help":"Elija una carpeta local con permiso de escritura. El servidor se detiene de forma segura y luego se reinicia.","configuration.choose_folder":"Elegir carpeta","configuration.relocate":"Reubicar","configuration.relocation_restarting":"La reubicación está programada; el servidor se está reiniciando.","configuration.relocation_failed":"No se pudo programar la reubicación."});
diff --git a/src/engineering_platform/installation_relocation.py b/src/engineering_platform/installation_relocation.py
new file mode 100644
index 00000000..037e4f33
--- /dev/null
+++ b/src/engineering_platform/installation_relocation.py
@@ -0,0 +1,158 @@
+"""Crash-safe, installation-owned relocation of the database and File Inbox."""
+from __future__ import annotations
+
+import os
+import json
+from pathlib import Path
+import sqlite3
+import tempfile
+from uuid import uuid4
+
+
+class RelocationError(ValueError):
+ """A requested local relocation cannot be safely completed."""
+
+
+def _directory(value: object) -> Path:
+ if not isinstance(value, str) or not value.strip():
+ raise RelocationError("LOCATION_REQUIRED")
+ path = Path(value).expanduser()
+ if not path.is_absolute() or not path.is_dir() or not os.access(path, os.W_OK | os.X_OK):
+ raise RelocationError("LOCATION_NOT_WRITABLE")
+ return path.resolve()
+
+
+_PENDING = "runtime/pending-relocation.json"
+_INBOX_SYSTEM_ENTRIES = {"file-inbox-heartbeat.json", "incoming", "accepted", "processing", "quarantine"}
+
+
+def _write_json_atomically(path: Path, value: dict[str, str]) -> None:
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ candidate = path.with_name(f".{path.name}.{uuid4().hex}")
+ try:
+ candidate.write_text(json.dumps(value, sort_keys=True), encoding="utf-8")
+ candidate.chmod(0o600)
+ os.replace(candidate, path)
+ finally:
+ candidate.unlink(missing_ok=True)
+
+
+def _pending_path(data_root: Path) -> Path:
+ return data_root.resolve() / _PENDING
+
+
+def _inbox_has_items(source: Path) -> bool:
+ if any(item.name not in _INBOX_SYSTEM_ENTRIES for item in source.iterdir()):
+ return True
+ return any(any((source / name).iterdir()) for name in ("incoming", "accepted", "processing", "quarantine") if (source / name).is_dir())
+
+
+def request(data_root: Path, kind: str, directory: object) -> dict[str, str]:
+ """Validate and persist a move request; it runs on the next clean startup."""
+ destination_directory = _directory(directory)
+ root = data_root.resolve()
+ if kind == "DATABASE":
+ source, destination = root / "engineering.db", destination_directory / "engineering.db"
+ if not source.is_file():
+ raise RelocationError("DATABASE_UNAVAILABLE")
+ if destination.exists() and destination.resolve() != source.resolve():
+ raise RelocationError("DATABASE_DESTINATION_EXISTS")
+ elif kind == "FILE_INBOX":
+ source, destination = root / "file-inbox", destination_directory / "file-inbox"
+ if not source.is_dir():
+ raise RelocationError("INBOX_UNAVAILABLE")
+ if _inbox_has_items(source):
+ raise RelocationError("INBOX_NOT_EMPTY")
+ if destination.exists() and any(destination.iterdir()):
+ raise RelocationError("INBOX_DESTINATION_NOT_EMPTY")
+ else:
+ raise RelocationError("RELOCATION_KIND_UNKNOWN")
+ pending = _pending_path(root)
+ if pending.exists():
+ raise RelocationError("RELOCATION_ALREADY_PENDING")
+ _write_json_atomically(pending, {"kind": kind, "directory": str(destination_directory)})
+ return {"previous": str(source.resolve()), "value": str(destination.resolve())}
+
+
+def _replace_with_link(source: Path, destination: Path) -> None:
+ candidate = source.with_name(f".{source.name}.relocating-{uuid4().hex}")
+ candidate.symlink_to(destination, target_is_directory=destination.is_dir())
+ os.replace(candidate, source)
+
+
+def _replace_directory_with_link(source: Path, destination: Path) -> None:
+ """macOS cannot replace a directory with a symlink in one rename."""
+ retired = source.with_name(f".{source.name}.relocated-{uuid4().hex}")
+ os.replace(source, retired)
+ try:
+ _replace_with_link(source, destination)
+ except Exception:
+ os.replace(retired, source)
+ raise
+
+
+def relocate_database(data_root: Path, directory: object) -> dict[str, str]:
+ """Copy via SQLite backup, verify it, then atomically repoint the live path."""
+ destination_directory = _directory(directory)
+ source = data_root.resolve() / "engineering.db"
+ previous = str(source.resolve())
+ destination = destination_directory / "engineering.db"
+ if not source.is_file():
+ raise RelocationError("DATABASE_UNAVAILABLE")
+ if destination.exists() and destination.resolve() == source.resolve():
+ return {"previous": previous, "value": str(destination.resolve())}
+ if destination.exists():
+ raise RelocationError("DATABASE_DESTINATION_EXISTS")
+ with tempfile.NamedTemporaryFile(prefix=".engineering-platform-", suffix=".db", dir=destination_directory, delete=False) as temporary:
+ candidate = Path(temporary.name)
+ try:
+ with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as old, sqlite3.connect(candidate) as copy:
+ old.backup(copy)
+ with sqlite3.connect(f"file:{candidate}?mode=ro", uri=True) as verify:
+ if verify.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
+ raise RelocationError("DATABASE_INTEGRITY_FAILED")
+ os.replace(candidate, destination)
+ _replace_with_link(source, destination)
+ except Exception:
+ candidate.unlink(missing_ok=True)
+ raise
+ return {"previous": previous, "value": str(destination.resolve())}
+
+
+def relocate_inbox(data_root: Path, directory: object) -> dict[str, str]:
+ """Create the destination and atomically repoint an empty File Inbox."""
+ destination_directory = _directory(directory)
+ source = data_root.resolve() / "file-inbox"
+ previous = str(source.resolve())
+ destination = destination_directory / "file-inbox"
+ if not source.is_dir():
+ raise RelocationError("INBOX_UNAVAILABLE")
+ if _inbox_has_items(source):
+ raise RelocationError("INBOX_NOT_EMPTY")
+ if destination.exists() and any(destination.iterdir()):
+ raise RelocationError("INBOX_DESTINATION_NOT_EMPTY")
+ destination.mkdir(mode=0o700, exist_ok=True)
+ for name in ("incoming", "accepted", "processing", "quarantine"):
+ (destination / name).mkdir(mode=0o700, exist_ok=True)
+ _replace_directory_with_link(source, destination)
+ return {"previous": previous, "value": str(destination.resolve())}
+
+
+def apply_pending(data_root: Path) -> dict[str, str] | None:
+ """Perform the durable request before any writer services are started."""
+ pending = _pending_path(data_root)
+ if not pending.exists():
+ return None
+ try:
+ request_data = json.loads(pending.read_text(encoding="utf-8"))
+ kind, directory = request_data["kind"], request_data["directory"]
+ if kind == "DATABASE":
+ result = relocate_database(data_root, directory)
+ elif kind == "FILE_INBOX":
+ result = relocate_inbox(data_root, directory)
+ else:
+ raise RelocationError("RELOCATION_KIND_UNKNOWN")
+ except (KeyError, TypeError, json.JSONDecodeError) as error:
+ raise RelocationError("RELOCATION_REQUEST_INVALID") from error
+ pending.unlink()
+ return {**result, "kind": kind}
diff --git a/src/engineering_platform/server.py b/src/engineering_platform/server.py
index 05e8ee51..802d923e 100644
--- a/src/engineering_platform/server.py
+++ b/src/engineering_platform/server.py
@@ -26,7 +26,7 @@
import subprocess # nosec B404
import sys
import time
-from threading import Lock
+from threading import Lock, Timer
from typing import Mapping, Protocol
from urllib.error import URLError
from urllib.request import urlopen
@@ -43,6 +43,7 @@
from . import external_producer_binding
from . import file_inbox
from . import host_admin
+from . import installation_relocation
from . import local_repository_binding
from . import project_topology
from . import submission_service
@@ -322,6 +323,21 @@ def _central_execution_active(data_root: Path) -> bool:
return row is not None
+def _choose_local_directory(data_root: Path) -> str | None:
+ """Use the host-native folder chooser only after an explicit Console action."""
+ if sys.platform != "darwin":
+ raise ValueError("LOCAL_DIRECTORY_PICKER_UNAVAILABLE")
+ result = LocalProcessProvider().execute(data_root, ("osascript", "-e", "POSIX path of (choose folder)"))
+ if result.returncode:
+ if "-128" in (result.stderr or ""):
+ return None
+ raise ValueError("LOCAL_DIRECTORY_PICKER_FAILED")
+ location = result.stdout.strip()
+ if not location or not Path(location).is_dir():
+ raise ValueError("LOCAL_DIRECTORY_PICKER_FAILED")
+ return location
+
+
def _install_provider(data_root: Path, provider: str) -> None:
"""Install one provider only through the Server installation boundary."""
if not _PROVIDER_INSTALL_LOCK.acquire(blocking=False):
@@ -2109,9 +2125,9 @@ def _central_database_section(data_root: Path) -> str:
' Platformbrede opslag voor projecten, uitvoeringen en configuratie.EP-database
'
'
' + location + b'
' + readonly = b'' + location + b'
' document = document.replace(b'data-i18n="section.host_components"', b'data-i18n="section.platform_components"') document = document.replace(b'data-i18n="description.host_components"', b'data-i18n="description.platform_components"') return document.replace( @@ -2471,6 +2488,27 @@ def _central_database_configuration(self, method: str) -> bool: if request.path == "/api/central-database/download" and method == "do_GET": self._send_central_database_backup() return True + if request.path == "/api/central-database/relocate/browse" and method == "do_POST": + try: + self._send(200, {"value": _choose_local_directory(self.server.data_root)}) # type: ignore[attr-defined] + except ValueError as error: + self._send(400, {"error": str(error)}) + return True + if request.path == "/api/central-database/relocate" and method == "do_POST": + try: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length).decode("utf-8")) if 0 < length <= 4096 else None + if not isinstance(payload, dict) or set(payload) != {"directory"} or _central_execution_active(self.server.data_root): # type: ignore[attr-defined] + raise ValueError("DATABASE_RELOCATION_BLOCKED") + result = installation_relocation.request(self.server.data_root, "DATABASE", payload["directory"]) # type: ignore[attr-defined] + except (ValueError, UnicodeDecodeError, json.JSONDecodeError, OSError, sqlite3.DatabaseError) as error: + self._send(409, {"error": str(error)}) + return True + self._send(202, {**result, "restarting": True}) + restart = Timer(0.5, lambda: os.kill(os.getpid(), signal.SIGTERM)) + restart.daemon = True + restart.start() + return True if request.path != "/api/central-database/configuration": return False if method == "do_GET": @@ -2617,6 +2655,27 @@ def _delegate_dashboard(self, method: str) -> None: self._console_route = console_route_ownership.route_owner(method.removeprefix("do_"), request.path) if method == "do_GET" and self._send_console_asset(request): return + if request.path == "/api/configuration/file-inbox/relocate/browse" and method == "do_POST": + try: + self._send(200, {"value": _choose_local_directory(self.server.data_root)}) # type: ignore[attr-defined] + except ValueError as error: + self._send(400, {"error": str(error)}) + return + if request.path == "/api/configuration/file-inbox/relocate" and method == "do_POST": + try: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length).decode("utf-8")) if 0 < length <= 4096 else None + if not isinstance(payload, dict) or set(payload) != {"directory"} or _central_execution_active(self.server.data_root): # type: ignore[attr-defined] + raise ValueError("INBOX_RELOCATION_BLOCKED") + result = installation_relocation.request(self.server.data_root, "FILE_INBOX", payload["directory"]) # type: ignore[attr-defined] + except (ValueError, UnicodeDecodeError, json.JSONDecodeError, OSError) as error: + self._send(409, {"error": str(error)}) + return + self._send(202, {**result, "restarting": True}) + restart = Timer(0.5, lambda: os.kill(os.getpid(), signal.SIGTERM)) + restart.daemon = True + restart.start() + return if method == "do_POST" and re.fullmatch(r"/api/configuration/inbox-location(?:/browse)?", request.path): self._send(410, {"error": "INBOX_WATCHER_CONFIGURATION_RETIRED"}) return @@ -3106,6 +3165,15 @@ def log_message(self, _format: str, *_args: object) -> None: def serve(data_root: Path) -> int: identity = initialize(data_root) + relocation = installation_relocation.apply_pending(data_root) + if relocation is not None: + _audit_configuration_change( + data_root, + scope="CENTRAL_DATABASE" if relocation["kind"] == "DATABASE" else "FILE_INBOX", + key="location", + previous=relocation["previous"], + value=relocation["value"], + ) config = ServerConfiguration.load(data_root) os.environ[SERVER_ENVIRONMENT_DATA_ROOT] = str(data_root.resolve()) os.environ[MANAGED_CODEX_CLI_PREFIX_ENVIRONMENT] = config.managed_codex_cli_prefix diff --git a/tests/engineering/test_installation_relocation.py b/tests/engineering/test_installation_relocation.py new file mode 100644 index 00000000..cd4a7f18 --- /dev/null +++ b/tests/engineering/test_installation_relocation.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path +import sqlite3 +import tempfile +import unittest + +from engineering_platform import installation_relocation + + +class InstallationRelocationTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) / "data" + self.root.mkdir() + self.target = Path(self.temporary.name) / "selected" + self.target.mkdir() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def database(self) -> Path: + path = self.root / "engineering.db" + with sqlite3.connect(path) as connection: + connection.execute("CREATE TABLE proof (value TEXT)") + connection.execute("INSERT INTO proof VALUES ('retained')") + return path + + def inbox(self) -> Path: + source = self.root / "file-inbox" + for name in ("incoming", "accepted", "processing", "quarantine"): + (source / name).mkdir(parents=True, exist_ok=True) + return source + + def test_database_request_is_applied_only_after_clean_start(self) -> None: + source = self.database() + requested = installation_relocation.request(self.root, "DATABASE", str(self.target)) + self.assertTrue(source.is_file()) + self.assertTrue((self.root / "runtime/pending-relocation.json").exists()) + + applied = installation_relocation.apply_pending(self.root) + + self.assertEqual(requested, {key: applied[key] for key in requested}) + self.assertTrue(source.is_symlink()) + self.assertEqual(source.resolve(), (self.target / "engineering.db").resolve()) + with sqlite3.connect(source) as connection: + self.assertEqual(connection.execute("SELECT value FROM proof").fetchone()[0], "retained") + self.assertFalse((self.root / "runtime/pending-relocation.json").exists()) + + def test_database_rejects_existing_destination(self) -> None: + source = self.database() + (self.target / "engineering.db").write_bytes(b"not ours") + with self.assertRaisesRegex(installation_relocation.RelocationError, "DATABASE_DESTINATION_EXISTS"): + installation_relocation.request(self.root, "DATABASE", str(self.target)) + self.assertFalse(source.is_symlink()) + + def test_inbox_rejects_items_and_moves_only_empty_inbox(self) -> None: + source = self.inbox() + (source / "incoming" / "waiting.json").write_text("{}", encoding="utf-8") + with self.assertRaisesRegex(installation_relocation.RelocationError, "INBOX_NOT_EMPTY"): + installation_relocation.request(self.root, "FILE_INBOX", str(self.target)) + (source / "incoming" / "waiting.json").unlink() + + installation_relocation.request(self.root, "FILE_INBOX", str(self.target)) + applied = installation_relocation.apply_pending(self.root) + + self.assertEqual(applied["kind"], "FILE_INBOX") + self.assertTrue(source.is_symlink()) + self.assertEqual(source.resolve(), (self.target / "file-inbox").resolve()) + self.assertTrue((self.target / "file-inbox" / "quarantine").is_dir()) + + def test_only_one_request_can_be_pending(self) -> None: + self.database() + self.inbox() + installation_relocation.request(self.root, "DATABASE", str(self.target)) + with self.assertRaisesRegex(installation_relocation.RelocationError, "RELOCATION_ALREADY_PENDING"): + installation_relocation.request(self.root, "FILE_INBOX", str(self.target))