@mriekki/project-forge 4.26.1 → 4.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/assets/authentication/distribution.json +90 -0
  2. package/assets/components.json +17 -3
  3. package/assets/core/distribution.json +12 -0
  4. package/assets/deployment/distribution.json +292 -68
  5. package/assets/generated/ASSEMBLY.json +150 -4
  6. package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
  7. package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
  8. package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
  9. package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
  10. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
  11. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
  12. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
  13. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
  14. package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
  15. package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
  16. package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
  17. package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
  18. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
  19. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
  20. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
  21. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
  22. package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
  23. package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
  24. package/assets/generated/authentication/tools/household_connect.py +145 -0
  25. package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
  26. package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
  27. package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
  28. package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
  29. package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
  30. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
  31. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
  32. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
  33. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
  34. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
  35. package/package.json +1 -1
  36. package/src/cli.mjs +32 -3
  37. package/src/engine.mjs +87 -5
@@ -0,0 +1,225 @@
1
+ """Read-only, exact Docker quiescence evidence for future Auth Kit recovery.
2
+
3
+ No container stop/restart, backup, key read, provider operation or Production
4
+ mutation is performed. Do not expose this primitive as a supported backup
5
+ workflow until Deployment Kit owns a reviewed quiescence orchestration driver.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shutil
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ from typing import Any, Callable
18
+
19
+ from .errors import DeploymentKitError
20
+
21
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
22
+ _CONTAINER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
23
+
24
+
25
+ def _exact_dict(value: object, required: set[str], context: str) -> dict:
26
+ if not isinstance(value, dict) or not required.issubset(value):
27
+ raise DeploymentKitError(f"Invalid {context} runtime evidence.")
28
+ return value
29
+
30
+
31
+ def _host_directory_is_real(path: Path) -> None:
32
+ # Local host only. A symlink anywhere in the owned mount path is
33
+ # forbidden even when its final resolution would be inside the root.
34
+ if not path.is_absolute():
35
+ raise DeploymentKitError("Auth recovery data mount must be absolute.")
36
+ for part in (path, *path.parents):
37
+ if part.is_symlink():
38
+ raise DeploymentKitError("Auth recovery mount may not use symlinks.")
39
+ try:
40
+ st = path.lstat()
41
+ except OSError as error:
42
+ raise DeploymentKitError("Auth recovery data directory is unavailable.") from error
43
+ if not stat.S_ISDIR(st.st_mode):
44
+ raise DeploymentKitError("Auth recovery data mount must be a directory.")
45
+
46
+
47
+ def verify_stopped_auth_container(
48
+ observed: dict[str, Any],
49
+ *,
50
+ container_name: str,
51
+ expected_owner: str,
52
+ expected_environment: str,
53
+ expected_image_digest: str,
54
+ expected_data_source: Path,
55
+ expected_data_destination: str,
56
+ owner_label: str,
57
+ environment_label: str,
58
+ required_auth_environment: dict[str, str],
59
+ ) -> dict[str, object]:
60
+ """Return only non-secret proof for an exactly owned, stopped container.
61
+
62
+ The caller must provide expected identity, image, labels, mount and auth
63
+ settings from a reviewed Project deployment receipt. This does not stop
64
+ the application or establish a viable restored application.
65
+ """
66
+ if not _CONTAINER.fullmatch(container_name):
67
+ raise DeploymentKitError("Invalid inspected container name.")
68
+ if not _HEX64.fullmatch(expected_image_digest.removeprefix("sha256:")) or (
69
+ not expected_image_digest.startswith("sha256:")
70
+ ):
71
+ raise DeploymentKitError("An exact immutable Docker image digest is required.")
72
+ if (not isinstance(expected_owner, str) or not expected_owner or
73
+ not isinstance(expected_environment, str) or not expected_environment or
74
+ not isinstance(owner_label, str) or not owner_label or
75
+ not isinstance(environment_label, str) or not environment_label or
76
+ type(required_auth_environment) is not dict or
77
+ not required_auth_environment or
78
+ any(not isinstance(k, str) or not k or not isinstance(v, str)
79
+ for k, v in required_auth_environment.items())):
80
+ raise DeploymentKitError("Exact Auth recovery ownership declaration required.")
81
+ mount_root = Path(expected_data_source)
82
+ _host_directory_is_real(mount_root)
83
+ if (not isinstance(expected_data_destination, str) or
84
+ not expected_data_destination.startswith("/") or
85
+ ".." in expected_data_destination.split("/")):
86
+ raise DeploymentKitError("Invalid reviewed container data destination.")
87
+
88
+ info = _exact_dict(observed, {
89
+ "Id", "Name", "Image", "State", "Config", "HostConfig", "Mounts"
90
+ }, "container")
91
+ runtime_id = info["Id"]
92
+ if (not isinstance(runtime_id, str) or not _HEX64.fullmatch(runtime_id) or
93
+ info["Name"] != "/" + container_name or
94
+ info["Image"] != expected_image_digest):
95
+ raise DeploymentKitError("Auth container identity or image drift detected.")
96
+ state = _exact_dict(info["State"], {"Running", "Restarting", "Status"}, "state")
97
+ policy = _exact_dict(info["HostConfig"], {"RestartPolicy"}, "host configuration")
98
+ restart = _exact_dict(policy["RestartPolicy"], {"Name"}, "restart policy")
99
+ if (state["Running"] is not False or state["Restarting"] is not False or
100
+ state["Status"] != "exited" or restart["Name"] != "no"):
101
+ raise DeploymentKitError(
102
+ "Auth recovery requires a stopped, restart-disabled exact container."
103
+ )
104
+
105
+ config = _exact_dict(info["Config"], {"Labels", "Env"}, "configuration")
106
+ labels = _exact_dict(config["Labels"], {owner_label, environment_label}, "labels")
107
+ if (labels[owner_label] != expected_owner or
108
+ labels[environment_label] != expected_environment):
109
+ raise DeploymentKitError("Auth runtime ownership labels do not match.")
110
+ raw_env = config["Env"]
111
+ if not isinstance(raw_env, list):
112
+ raise DeploymentKitError("Malformed Auth runtime environment.")
113
+ env: dict[str, str] = {}
114
+ for item in raw_env:
115
+ if not isinstance(item, str) or "=" not in item:
116
+ raise DeploymentKitError("Malformed Auth runtime environment.")
117
+ name, value = item.split("=", 1)
118
+ if name in env or not name:
119
+ raise DeploymentKitError("Duplicate Auth runtime environment key.")
120
+ env[name] = value
121
+ for key, value in required_auth_environment.items():
122
+ if env.get(key) != value:
123
+ raise DeploymentKitError("Auth runtime binding differs from approved receipt.")
124
+
125
+ mounts = info["Mounts"]
126
+ if not isinstance(mounts, list):
127
+ raise DeploymentKitError("Malformed Auth container mount inventory.")
128
+ data = [m for m in mounts if isinstance(m, dict)
129
+ and m.get("Destination") == expected_data_destination]
130
+ if (len(data) != 1 or data[0].get("Type") != "bind" or
131
+ data[0].get("RW") is not True or
132
+ data[0].get("Source") != str(mount_root)):
133
+ raise DeploymentKitError("Auth owned data bind is not the reviewed mount.")
134
+ if any(not isinstance(m, dict) for m in mounts):
135
+ raise DeploymentKitError("Malformed Auth container mount inventory.")
136
+
137
+ return {
138
+ "stopped": True,
139
+ "runtimeId": runtime_id,
140
+ "imageDigest": expected_image_digest,
141
+ }
142
+
143
+
144
+
145
+ def verify_running_auth_container(
146
+ observed: dict[str, Any], **binding: Any
147
+ ) -> dict[str, object]:
148
+ """Verify the *same* strict Auth owner/image/mount, but in running state.
149
+
150
+ Reuse the stopped proof's complete configuration inspection after
151
+ independently checking the actual running state. The normalized copy
152
+ below is verification-only and never sent to Docker.
153
+ """
154
+ if type(observed) is not dict:
155
+ raise DeploymentKitError("Malformed running Auth Docker inspection.")
156
+ state = observed.get("State")
157
+ if (type(state) is not dict or state.get("Running") is not True
158
+ or state.get("Restarting") is not False
159
+ or state.get("Status") != "running"):
160
+ raise DeploymentKitError("Exact Auth container is not running.")
161
+ projection = {
162
+ **observed,
163
+ "State": {**state, "Running": False, "Status": "exited"},
164
+ }
165
+ proof = verify_stopped_auth_container(projection, **binding)
166
+ return {
167
+ "running": True,
168
+ "runtimeId": proof["runtimeId"],
169
+ "imageDigest": proof["imageDigest"],
170
+ }
171
+
172
+
173
+ def docker_auth_quiescence_guard(
174
+ *,
175
+ container_name: str,
176
+ expected_owner: str,
177
+ expected_environment: str,
178
+ expected_image_digest: str,
179
+ expected_data_source: Path,
180
+ expected_data_destination: str,
181
+ owner_label: str,
182
+ environment_label: str,
183
+ required_auth_environment: dict[str, str],
184
+ ) -> Callable[[], dict[str, object]]:
185
+ """Build a read-only local Linux Docker inspect guard, to call twice.
186
+
187
+ Raw Docker inspect includes sensitive environment material. Never log the
188
+ response, return it, or include stderr in user-facing failure messages.
189
+ """
190
+ if sys.platform != "linux":
191
+ raise DeploymentKitError(
192
+ "Auth recovery Docker quiescence requires a reviewed Linux host driver."
193
+ )
194
+ if not _CONTAINER.fullmatch(container_name):
195
+ raise DeploymentKitError("Invalid container identity.")
196
+ docker = shutil.which("docker")
197
+ if docker is None:
198
+ raise DeploymentKitError("Docker runtime is unavailable.")
199
+
200
+ def inspect() -> dict[str, object]:
201
+ try:
202
+ cmd = subprocess.run(
203
+ [docker, "inspect", "--format", "{{json .}}", container_name],
204
+ capture_output=True, text=True, check=False,
205
+ timeout=15, encoding="utf-8", errors="replace",
206
+ )
207
+ if cmd.returncode != 0 or len(cmd.stdout) > 256 * 1024:
208
+ raise DeploymentKitError("Auth runtime inspect failed closed.")
209
+ observed = json.loads(cmd.stdout)
210
+ except (OSError, subprocess.TimeoutExpired, ValueError) as error:
211
+ raise DeploymentKitError(
212
+ "Auth runtime evidence is unavailable or invalid."
213
+ ) from error
214
+ return verify_stopped_auth_container(
215
+ observed, container_name=container_name,
216
+ expected_owner=expected_owner,
217
+ expected_environment=expected_environment,
218
+ expected_image_digest=expected_image_digest,
219
+ expected_data_source=expected_data_source,
220
+ expected_data_destination=expected_data_destination,
221
+ owner_label=owner_label,
222
+ environment_label=environment_label,
223
+ required_auth_environment=required_auth_environment,
224
+ )
225
+ return inspect
@@ -0,0 +1,455 @@
1
+ """Disposable-only Auth Kit backup lifecycle with exclusive host locking.
2
+
3
+ This is a low-level *explicitly authorized* local Linux driver, not a cron
4
+ job, Production command, generic application recovery proof or public CLI.
5
+ It never provisions providers or modifies Caddy/Cloudflare. The only Docker
6
+ mutations are stop/start of the exact pre-reviewed disposable container ID.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from contextlib import contextmanager
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import shutil
16
+ import stat
17
+ import subprocess
18
+ import sys
19
+
20
+ from .auth_recovery_capture import (
21
+ _load_pinned_helper, capture_reviewed_stopped_auth_project,
22
+ )
23
+ from .auth_recovery_guard import verify_running_auth_container, verify_stopped_auth_container
24
+ from .errors import DeploymentKitError
25
+
26
+
27
+ def _real_private_directory(directory: Path) -> Path:
28
+ path = Path(os.path.abspath(directory))
29
+ for part in (path, *path.parents):
30
+ if part.is_symlink():
31
+ raise DeploymentKitError("Auth recovery path contains a symlink.")
32
+ try:
33
+ mode = path.lstat()
34
+ except OSError:
35
+ raise DeploymentKitError("Auth recovery directory is unavailable.") from None
36
+ if not stat.S_ISDIR(mode.st_mode) or stat.S_IMODE(mode.st_mode) & 0o077:
37
+ raise DeploymentKitError("Auth recovery directory must be owner-private.")
38
+ return path
39
+
40
+
41
+ def _separated(path: Path, other: Path) -> bool:
42
+ a = Path(os.path.abspath(path))
43
+ b = Path(os.path.abspath(other))
44
+ return a != b and a not in b.parents and b not in a.parents
45
+
46
+
47
+ @contextmanager
48
+ def _exclusive_lifecycle_lock(lock_directory: Path):
49
+ import fcntl # Linux-only, deferred so other Project tools still import on Windows.
50
+
51
+ directory = _real_private_directory(lock_directory)
52
+ lock_path = directory / "auth-backup.lock"
53
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
54
+ try:
55
+ fd = os.open(lock_path, flags, 0o600)
56
+ except OSError:
57
+ raise DeploymentKitError("Private Auth backup lock could not be opened.") from None
58
+ try:
59
+ status = os.fstat(fd)
60
+ if (not stat.S_ISREG(status.st_mode) or status.st_nlink != 1 or
61
+ stat.S_IMODE(status.st_mode) & 0o077):
62
+ raise DeploymentKitError("Auth recovery lock is not a private regular file.")
63
+ try:
64
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
65
+ except BlockingIOError:
66
+ raise DeploymentKitError("Another Auth recovery operation holds the lock.") from None
67
+ yield
68
+ finally:
69
+ os.close(fd)
70
+
71
+
72
+ def _docker_inspect(docker: str, container_name: str) -> dict:
73
+ try:
74
+ result = subprocess.run(
75
+ [docker, "inspect", "--format", "{{json .}}", container_name],
76
+ text=True, capture_output=True, timeout=15, check=False,
77
+ encoding="utf-8", errors="replace",
78
+ )
79
+ if (result.returncode != 0 or
80
+ not 0 < len(result.stdout) <= 256 * 1024):
81
+ raise DeploymentKitError("Exact disposable Docker inspection failed.")
82
+ observed = json.loads(result.stdout)
83
+ if type(observed) is not dict:
84
+ raise DeploymentKitError("Malformed disposable Docker state.")
85
+ return observed
86
+ except (OSError, subprocess.TimeoutExpired, ValueError):
87
+ raise DeploymentKitError("Exact disposable Docker inspection unavailable.") from None
88
+
89
+
90
+ def _docker_action(docker: str, action: str, runtime_id: str):
91
+ if action not in {"stop", "start"}:
92
+ raise DeploymentKitError("Unsupported Auth lifecycle action.")
93
+ argv = ([docker, "stop", "--time", "20", runtime_id] if action == "stop"
94
+ else [docker, "start", runtime_id])
95
+ try:
96
+ result = subprocess.run(
97
+ argv, text=True, capture_output=True, timeout=40,
98
+ check=False, encoding="utf-8", errors="replace",
99
+ )
100
+ except (OSError, subprocess.TimeoutExpired):
101
+ raise DeploymentKitError("Disposable Auth Docker lifecycle action failed.") from None
102
+ if result.returncode != 0:
103
+ # Docker stderr/stdout must never be logged (may expose host state).
104
+ raise DeploymentKitError("Disposable Auth Docker lifecycle action was rejected.")
105
+
106
+
107
+ def _resume_exact_disposable(
108
+ docker: str, binding: dict, initial: dict
109
+ ) -> bool:
110
+ """Attempt resumption only of the original reviewed container identity."""
111
+ current = _docker_inspect(docker, binding["container_name"])
112
+ try:
113
+ running = verify_running_auth_container(current, **binding)
114
+ if running["runtimeId"] == initial["runtimeId"]:
115
+ return True # Concurrently resumed: no duplicate start needed.
116
+ except DeploymentKitError:
117
+ pass
118
+ # A changed/missing container or ownership drift MUST NOT be started.
119
+ stopped = verify_stopped_auth_container(current, **binding)
120
+ if stopped["runtimeId"] != initial["runtimeId"]:
121
+ raise DeploymentKitError("Disposable Auth identity changed; manual recovery required.")
122
+ _docker_action(docker, "start", initial["runtimeId"])
123
+ resumed = verify_running_auth_container(
124
+ _docker_inspect(docker, binding["container_name"]), **binding)
125
+ if resumed["runtimeId"] != initial["runtimeId"]:
126
+ raise DeploymentKitError("Disposable Auth identity drift after start.")
127
+ return True
128
+
129
+
130
+ RESTART_JOURNAL = "auth-backup-restart.json"
131
+
132
+
133
+ def _binding_fingerprint(binding: dict) -> str:
134
+ """Bind private restart intent to an independently reviewed Docker identity.
135
+
136
+ Only a SHA-256 fingerprint is retained, never raw environment settings.
137
+ """
138
+ if type(binding) is not dict:
139
+ raise DeploymentKitError("Reviewed Docker binding is unavailable.")
140
+ allowed = {
141
+ "container_name", "expected_owner", "expected_environment",
142
+ "expected_image_digest", "expected_data_source",
143
+ "expected_data_destination", "owner_label", "environment_label",
144
+ "required_auth_environment",
145
+ }
146
+ if set(binding) != allowed or not isinstance(binding["expected_data_source"], Path):
147
+ raise DeploymentKitError("Reviewed Docker binding is incomplete.")
148
+ normalized = {
149
+ **binding,
150
+ "expected_data_source": str(Path(os.path.abspath(binding["expected_data_source"]))),
151
+ }
152
+ try:
153
+ encoded = json.dumps(
154
+ normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=True,
155
+ ).encode("utf-8")
156
+ except (TypeError, ValueError) as exc:
157
+ raise DeploymentKitError("Reviewed Docker binding is malformed.") from exc
158
+ return hashlib.sha256(encoded).hexdigest()
159
+
160
+
161
+ def _journal_payload(binding: dict, initial: dict, backup_name: str) -> dict:
162
+ import re
163
+ if (type(initial) is not dict or
164
+ type(initial.get("runtimeId")) is not str or
165
+ re.fullmatch(r"[0-9a-f]{64}", initial["runtimeId"]) is None or
166
+ type(initial.get("imageDigest")) is not str or
167
+ re.fullmatch(r"sha256:[0-9a-f]{64}", initial["imageDigest"]) is None or
168
+ initial["imageDigest"] != binding.get("expected_image_digest") or
169
+ type(backup_name) is not str or not 1 <= len(backup_name) <= 128 or
170
+ "/" in backup_name or "\\" in backup_name):
171
+ raise DeploymentKitError("Unsafe original Docker identity for recovery journal.")
172
+ return {
173
+ "schemaVersion": 1,
174
+ "purpose": "disposable-auth-resume",
175
+ "runtimeId": initial["runtimeId"],
176
+ "imageDigest": initial["imageDigest"],
177
+ "bindingFingerprint": _binding_fingerprint(binding),
178
+ "backupSetName": backup_name,
179
+ }
180
+
181
+
182
+ def _sync_private_directory(directory: Path) -> None:
183
+ if sys.platform != "linux":
184
+ raise DeploymentKitError("Durable Auth journal requires Linux.")
185
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
186
+ try:
187
+ fd = os.open(directory, flags)
188
+ try:
189
+ os.fsync(fd)
190
+ finally:
191
+ os.close(fd)
192
+ except OSError:
193
+ raise DeploymentKitError("Durable Auth recovery journal synchronization failed.") from None
194
+
195
+
196
+ def _create_restart_journal(lock_directory: Path, binding: dict,
197
+ initial: dict, backup_name: str) -> None:
198
+ """Durably publish *before* stopping Docker; never overwrite prior intent."""
199
+ directory = _real_private_directory(lock_directory)
200
+ payload = _journal_payload(binding, initial, backup_name)
201
+ path = directory / RESTART_JOURNAL
202
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
203
+ try:
204
+ fd = os.open(path, flags, 0o600)
205
+ except OSError:
206
+ raise DeploymentKitError("Existing or unsafe pending Auth restart journal.") from None
207
+ try:
208
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
209
+ json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
210
+ handle.write("\n")
211
+ handle.flush()
212
+ os.fsync(handle.fileno())
213
+ _sync_private_directory(directory)
214
+ except Exception:
215
+ # If persistence is uncertain, retain the journal and refuse stop.
216
+ raise DeploymentKitError("Durable Auth restart journal could not be committed.") from None
217
+
218
+
219
+ def _read_restart_journal(lock_directory: Path, binding: dict) -> dict | None:
220
+ directory = _real_private_directory(lock_directory)
221
+ path = directory / RESTART_JOURNAL
222
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
223
+ try:
224
+ fd = os.open(path, flags)
225
+ except FileNotFoundError:
226
+ return None
227
+ except OSError:
228
+ raise DeploymentKitError("Auth restart journal cannot be safely read.") from None
229
+ try:
230
+ st = os.fstat(fd)
231
+ if (not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or
232
+ stat.S_IMODE(st.st_mode) & 0o077 or not 0 < st.st_size <= 4096):
233
+ raise DeploymentKitError("Auth restart journal is not an owner-private regular file.")
234
+ with os.fdopen(fd, "r", encoding="utf-8") as handle:
235
+ fd = -1
236
+ content = handle.read(4097)
237
+ if len(content) > 4096:
238
+ raise DeploymentKitError("Auth restart journal exceeds size limit.")
239
+ def unique_pairs(pairs):
240
+ out = {}
241
+ for key, value in pairs:
242
+ if key in out:
243
+ raise ValueError("Duplicate journal key")
244
+ out[key] = value
245
+ return out
246
+ value = json.loads(content, object_pairs_hook=unique_pairs)
247
+ except (ValueError, UnicodeError, OSError) as exc:
248
+ raise DeploymentKitError("Auth restart journal is malformed.") from None
249
+ finally:
250
+ if fd >= 0:
251
+ os.close(fd)
252
+ if (type(value) is not dict or
253
+ set(value) != {
254
+ "schemaVersion", "purpose", "runtimeId", "imageDigest",
255
+ "bindingFingerprint", "backupSetName",
256
+ } or type(value["schemaVersion"]) is not int or
257
+ value["schemaVersion"] != 1 or
258
+ value["purpose"] != "disposable-auth-resume" or
259
+ value["bindingFingerprint"] != _binding_fingerprint(binding)):
260
+ raise DeploymentKitError("Auth journal does not match reviewed Docker identity.")
261
+ _journal_payload(binding, {
262
+ "runtimeId": value["runtimeId"],
263
+ "imageDigest": value["imageDigest"],
264
+ }, value["backupSetName"])
265
+ return value
266
+
267
+
268
+ def _clear_restart_journal(lock_directory: Path, binding: dict) -> None:
269
+ current = _read_restart_journal(lock_directory, binding)
270
+ if current is None:
271
+ raise DeploymentKitError("Required Auth restart journal disappeared.")
272
+ try:
273
+ os.unlink(Path(lock_directory) / RESTART_JOURNAL)
274
+ except OSError:
275
+ raise DeploymentKitError("Auth restart journal could not be cleared.") from None
276
+ _sync_private_directory(Path(lock_directory))
277
+
278
+
279
+ def recover_interrupted_disposable_auth(
280
+ *, lock_directory: Path, reviewed_docker_binding: dict,
281
+ authorize_disposable_resume: bool = False,
282
+ ) -> dict:
283
+ """Explicitly resume only the original journaled disposable Docker ID.
284
+
285
+ A separately supervised service or human may invoke this when the
286
+ capture process was interrupted. No installed timer, start unit or
287
+ Production CLI is provided. Health and traffic readiness remain unknown.
288
+ """
289
+ if sys.platform != "linux" or authorize_disposable_resume is not True:
290
+ raise DeploymentKitError("Explicit Linux disposable Auth recovery authorization required.")
291
+ binding = reviewed_docker_binding
292
+ if type(binding) is not dict or binding.get("expected_environment") != "disposable":
293
+ raise DeploymentKitError("Only reviewed disposable Auth recovery is supported.")
294
+ with _exclusive_lifecycle_lock(lock_directory):
295
+ journal = _read_restart_journal(lock_directory, binding)
296
+ if journal is None:
297
+ return {
298
+ "state": "no-pending-restart",
299
+ "mutated": False,
300
+ "runtimeResumedVerified": False,
301
+ "healthVerified": False,
302
+ "applicationRecoveryReady": False,
303
+ }
304
+ docker = shutil.which("docker")
305
+ if docker is None:
306
+ raise DeploymentKitError("Docker is unavailable for Auth restart recovery.")
307
+ was_stopped = False
308
+ current = _docker_inspect(docker, binding["container_name"])
309
+ try:
310
+ current_proof = verify_stopped_auth_container(current, **binding)
311
+ was_stopped = True
312
+ except DeploymentKitError:
313
+ current_proof = verify_running_auth_container(current, **binding)
314
+ if current_proof["runtimeId"] != journal["runtimeId"]:
315
+ raise DeploymentKitError("Journaled original Docker ID changed; manual recovery required.")
316
+ resumed = _resume_exact_disposable(
317
+ docker, binding, {
318
+ "runtimeId": journal["runtimeId"],
319
+ "imageDigest": journal["imageDigest"],
320
+ })
321
+ if resumed is not True:
322
+ raise DeploymentKitError("Journaled Auth resumption not proven.")
323
+ _clear_restart_journal(lock_directory, binding)
324
+ return {
325
+ "state": "original-disposable-running",
326
+ "mutated": was_stopped,
327
+ "runtimeResumedVerified": True,
328
+ "healthVerified": False,
329
+ "applicationRecoveryReady": False,
330
+ "scheduledBackupVerified": False,
331
+ "alertInboxReceiptVerified": False,
332
+ "normalTrafficReadinessProven": False,
333
+ }
334
+
335
+
336
+ def capture_with_disposable_service_pause(
337
+ *, project_root: Path, source_data_root: Path, destination: Path,
338
+ lock_directory: Path, declaration: dict, reviewed_auth_revision: str,
339
+ reviewed_recovery_sha256: str, reviewed_docker_binding: dict,
340
+ authorize_disposable_stop: bool = False,
341
+ ) -> dict:
342
+ """Capture an exact disposable Auth Project, then prove Docker is running.
343
+
344
+ The caller authorizes this single controlled disposable stop/start. This
345
+ function cannot guarantee service health, normal traffic readiness,
346
+ backup scheduling, secure host recovery from process death, or provider
347
+ recovery. No Production execution and no CLI entrypoint are provided.
348
+ """
349
+ if sys.platform != "linux":
350
+ raise DeploymentKitError("Disposable Auth lifecycle requires Linux Docker.")
351
+ if authorize_disposable_stop is not True:
352
+ raise DeploymentKitError("Explicit disposable Auth stop authorization required.")
353
+ if type(reviewed_docker_binding) is not dict or set(reviewed_docker_binding) != {
354
+ "container_name", "expected_owner", "expected_environment",
355
+ "expected_image_digest", "expected_data_source",
356
+ "expected_data_destination", "owner_label", "environment_label",
357
+ "required_auth_environment",
358
+ }:
359
+ raise DeploymentKitError("Reviewed exact disposable Docker binding required.")
360
+ binding = reviewed_docker_binding
361
+ if binding.get("expected_environment") != "disposable":
362
+ raise DeploymentKitError("Only disposable Auth containers may be controlled.")
363
+ project = Path(os.path.abspath(project_root))
364
+ source = Path(os.path.abspath(source_data_root))
365
+ destination = Path(os.path.abspath(destination))
366
+ lock = _real_private_directory(lock_directory)
367
+ backup_parent = _real_private_directory(destination.parent)
368
+ if (not _separated(source, project) or not _separated(destination, source)
369
+ or not _separated(destination, project)
370
+ or not _separated(backup_parent, source)
371
+ or not _separated(backup_parent, project)
372
+ or not _separated(lock, source) or not _separated(lock, project)
373
+ or not _separated(lock, backup_parent)):
374
+ raise DeploymentKitError("Disposable source, Project, backups and lock must be isolated.")
375
+ if destination.exists() or destination.is_symlink():
376
+ raise DeploymentKitError("Refusing existing Auth backup destination.")
377
+ # Verify pinned managed helper and declaration BEFORE ever stopping a
378
+ # service, to avoid downtime for an unsupported Auth version.
379
+ helper = _load_pinned_helper(project, reviewed_auth_revision, reviewed_recovery_sha256)
380
+ try:
381
+ helper.validate_declaration(declaration)
382
+ if not helper._valid_set_name(destination.name):
383
+ raise ValueError("Invalid canonical set name")
384
+ except Exception:
385
+ raise DeploymentKitError("Auth recovery declaration or set name is invalid.") from None
386
+ if (not isinstance(binding["expected_data_source"], Path)
387
+ or Path(os.path.abspath(binding["expected_data_source"])) != source
388
+ or type(binding["expected_data_destination"]) is not str
389
+ or type(binding["required_auth_environment"]) is not dict):
390
+ raise DeploymentKitError("Auth Docker source and environment bindings differ.")
391
+ mount = binding["expected_data_destination"].rstrip("/")
392
+ env = binding["required_auth_environment"]
393
+ if (env.get("ProjectForge__Authentication__DbPath") != mount + "/auth.sqlite"
394
+ or env.get("ProjectForge__Authentication__DataProtectionKeysPath")
395
+ != mount + "/keys"):
396
+ raise DeploymentKitError("Auth SQLite and key-ring bindings differ.")
397
+ _real_private_directory(source)
398
+ docker = shutil.which("docker")
399
+ if not docker:
400
+ raise DeploymentKitError("Linux Docker is unavailable.")
401
+
402
+ with _exclusive_lifecycle_lock(lock):
403
+ if destination.exists() or destination.is_symlink():
404
+ raise DeploymentKitError("Recovery destination appeared while acquiring lock.")
405
+ initial = verify_running_auth_container(
406
+ _docker_inspect(docker, binding["container_name"]), **binding)
407
+ _create_restart_journal(lock, binding, initial, destination.name)
408
+ failure = False
409
+ resumed = False
410
+ capture = None
411
+ stop_attempted = False
412
+ try:
413
+ stop_attempted = True
414
+ _docker_action(docker, "stop", initial["runtimeId"])
415
+ stopped = verify_stopped_auth_container(
416
+ _docker_inspect(docker, binding["container_name"]), **binding)
417
+ if stopped["runtimeId"] != initial["runtimeId"]:
418
+ raise DeploymentKitError("Container identity changed while stopping.")
419
+ capture = capture_reviewed_stopped_auth_project(
420
+ project_root=project, source_data_root=source,
421
+ destination=destination, declaration=declaration,
422
+ reviewed_auth_revision=reviewed_auth_revision,
423
+ reviewed_recovery_sha256=reviewed_recovery_sha256,
424
+ reviewed_docker_binding=binding,
425
+ )
426
+ except Exception:
427
+ failure = True
428
+ finally:
429
+ if stop_attempted:
430
+ try:
431
+ resumed = _resume_exact_disposable(docker, binding, initial)
432
+ except Exception:
433
+ # Journal deliberately remains for independent recovery.
434
+ raise DeploymentKitError(
435
+ "Disposable Auth resumption not proven; manual recovery required."
436
+ ) from None
437
+ if resumed:
438
+ _clear_restart_journal(lock, binding)
439
+ if failure or capture is None:
440
+ raise DeploymentKitError(
441
+ "Disposable Auth capture failed; original container is running."
442
+ ) from None
443
+ return {
444
+ "backupArtifactIntegrityVerified": (
445
+ capture.get("backupArtifactIntegrityVerified") is True
446
+ ),
447
+ "runtimeResumedVerified": resumed,
448
+ "healthVerified": False,
449
+ "applicationRecoveryReady": False,
450
+ "scheduledBackupVerified": False,
451
+ "alertInboxReceiptVerified": False,
452
+ "normalTrafficReadinessProven": False,
453
+ "quiescenceGuardRuntimeId": initial["runtimeId"],
454
+ "quiescenceGuardImageDigest": initial["imageDigest"],
455
+ }