@mriekki/project-forge 4.26.0 → 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.
- package/assets/authentication/distribution.json +90 -0
- package/assets/components.json +18 -4
- package/assets/core/distribution.json +12 -0
- package/assets/deployment/distribution.json +292 -68
- package/assets/generated/ASSEMBLY.json +152 -6
- package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
- package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
- package/assets/generated/authentication/tools/household_connect.py +145 -0
- package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
- package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
- package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
- package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
- package/assets/generated/core/tools/setup_maintenance/maintenance.py +162 -6
- package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
- package/package.json +1 -1
- package/src/cli.mjs +32 -3
- package/src/engine.mjs +87 -5
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Manually reviewed disposable Auth retention operations; no scheduler/Production wiring.
|
|
2
|
+
|
|
3
|
+
A plan is private and non-secret, bound to the exact generated-Project helper,
|
|
4
|
+
Docker owner binding, most recently verified backup and inventory. Applying
|
|
5
|
+
requires the human-reviewed SHA-256 of that private plan, plus explicit
|
|
6
|
+
disposable deletion authorization. This is not host or inbox acceptance.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from .auth_recovery_capture import _load_pinned_helper
|
|
20
|
+
from .auth_recovery_lifecycle import _binding_fingerprint
|
|
21
|
+
from .auth_recovery_schedule import (
|
|
22
|
+
_operation_lock, _read_json, _write_private_json,
|
|
23
|
+
inspect_disposable_backup_status, load_disposable_operation_config,
|
|
24
|
+
)
|
|
25
|
+
from .errors import DeploymentKitError
|
|
26
|
+
|
|
27
|
+
_PLAN = "auth-retention-reviewed-plan.json"
|
|
28
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _canonical_json(value: dict) -> bytes:
|
|
32
|
+
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _verified_backup(config: dict) -> dict:
|
|
36
|
+
report = inspect_disposable_backup_status(config)
|
|
37
|
+
attempt = report["lastAttempt"]
|
|
38
|
+
latest = report["lastSuccess"]
|
|
39
|
+
if (report["state"] != "fresh" or type(attempt) is not dict or
|
|
40
|
+
type(latest) is not dict or attempt["outcome"] != "success" or
|
|
41
|
+
attempt["backupArtifactIntegrityVerified"] is not True or
|
|
42
|
+
attempt["runtimeResumedVerified"] is not True or
|
|
43
|
+
attempt["setName"] != latest["setName"] or
|
|
44
|
+
attempt["completedAt"] != latest["completedAt"]):
|
|
45
|
+
raise DeploymentKitError(
|
|
46
|
+
"A current verified disposable Auth backup and exact Docker resumption are required."
|
|
47
|
+
)
|
|
48
|
+
return latest
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _verified_helper(config: dict):
|
|
52
|
+
helper = _load_pinned_helper(
|
|
53
|
+
config["project"], config["revision"], config["helperSha"])
|
|
54
|
+
if (not callable(getattr(helper, "plan_verified_retention", None)) or
|
|
55
|
+
not callable(getattr(helper, "apply_reviewed_disposable_retention", None))):
|
|
56
|
+
raise DeploymentKitError(
|
|
57
|
+
"Reviewed generated Auth helper does not support disposable retention."
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
helper.validate_declaration(config["declaration"])
|
|
61
|
+
except Exception:
|
|
62
|
+
raise DeploymentKitError("Reviewed Auth recovery declaration is invalid.") from None
|
|
63
|
+
return helper
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _binding_record(config: dict, last: dict, keep_last: int) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"schemaVersion": 1,
|
|
69
|
+
"environment": "disposable",
|
|
70
|
+
"projectId": config["declaration"]["projectId"],
|
|
71
|
+
"authRevision": config["revision"],
|
|
72
|
+
"recoverySha256": config["helperSha"],
|
|
73
|
+
"dockerBindingSha256": _binding_fingerprint(config["binding"]),
|
|
74
|
+
"backupsRoot": str(config["backups"]),
|
|
75
|
+
"latestSuccessfulSetName": last["setName"],
|
|
76
|
+
"latestSuccessfulAt": last["completedAt"],
|
|
77
|
+
"keepLast": keep_last,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_reviewed_disposable_retention_plan(
|
|
82
|
+
config: dict, *, keep_last: int = 7,
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""Write only a private review artifact, no backup deletion."""
|
|
85
|
+
if type(keep_last) is not int or not 2 <= keep_last <= 365:
|
|
86
|
+
raise DeploymentKitError("Auth retention must preserve at least two recovery sets.")
|
|
87
|
+
with _operation_lock(config["status"]):
|
|
88
|
+
last = _verified_backup(config)
|
|
89
|
+
helper = _verified_helper(config)
|
|
90
|
+
try:
|
|
91
|
+
preview = helper.plan_verified_retention(
|
|
92
|
+
config["backups"], config["declaration"], keep_last=keep_last)
|
|
93
|
+
except Exception:
|
|
94
|
+
raise DeploymentKitError("Auth retention inventory could not be verified.") from None
|
|
95
|
+
if (type(preview) is not dict or preview.get("retentionPlanOnly") is not True or
|
|
96
|
+
preview.get("deletionPerformed") is not False or
|
|
97
|
+
last["setName"] not in preview.get("retainedRecoverySetNames", [])):
|
|
98
|
+
raise DeploymentKitError("Current verified backup must remain in the retained inventory.")
|
|
99
|
+
envelope = {**_binding_record(config, last, keep_last), "reviewedPlan": preview}
|
|
100
|
+
digest = hashlib.sha256(_canonical_json(envelope)).hexdigest()
|
|
101
|
+
_write_private_json(config["status"] / _PLAN, envelope, replace=True)
|
|
102
|
+
return {
|
|
103
|
+
"planSha256": digest,
|
|
104
|
+
"candidateCount": len(preview["retentionCandidateNames"]),
|
|
105
|
+
"deletionPerformed": False,
|
|
106
|
+
"reviewRequiredBeforeApply": True,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def apply_reviewed_disposable_retention(
|
|
111
|
+
config: dict, *, expected_plan_sha256: str,
|
|
112
|
+
authorize_disposable_delete: bool = False,
|
|
113
|
+
) -> dict:
|
|
114
|
+
"""Apply only an exact operator-reviewed private plan, never automatically."""
|
|
115
|
+
if (authorize_disposable_delete is not True or
|
|
116
|
+
type(expected_plan_sha256) is not str or
|
|
117
|
+
_SHA256.fullmatch(expected_plan_sha256) is None):
|
|
118
|
+
raise DeploymentKitError("Explicit disposable retention approval and reviewed SHA-256 required.")
|
|
119
|
+
with _operation_lock(config["status"]):
|
|
120
|
+
last = _verified_backup(config)
|
|
121
|
+
helper = _verified_helper(config)
|
|
122
|
+
envelope = _read_json(config["status"] / _PLAN)
|
|
123
|
+
if hashlib.sha256(_canonical_json(envelope)).hexdigest() != expected_plan_sha256:
|
|
124
|
+
raise DeploymentKitError("Disposable retention reviewed-plan hash differs.")
|
|
125
|
+
if (set(envelope) != set(_binding_record(config, last, 7)) | {"reviewedPlan"} or
|
|
126
|
+
type(envelope.get("keepLast")) is not int or
|
|
127
|
+
not 2 <= envelope["keepLast"] <= 365):
|
|
128
|
+
raise DeploymentKitError("Disposable retention plan schema is invalid.")
|
|
129
|
+
if (envelope != {**_binding_record(config, last, envelope["keepLast"]),
|
|
130
|
+
"reviewedPlan": envelope["reviewedPlan"]}):
|
|
131
|
+
raise DeploymentKitError("Reviewed retention plan does not match generated Project.")
|
|
132
|
+
preview = envelope["reviewedPlan"]
|
|
133
|
+
try:
|
|
134
|
+
current = helper.plan_verified_retention(
|
|
135
|
+
config["backups"], config["declaration"], keep_last=envelope["keepLast"])
|
|
136
|
+
except Exception:
|
|
137
|
+
raise DeploymentKitError("Current retention inventory could not be verified.") from None
|
|
138
|
+
if (current != preview or
|
|
139
|
+
last["setName"] not in current["retainedRecoverySetNames"]):
|
|
140
|
+
raise DeploymentKitError("Retention inventory changed after operator review.")
|
|
141
|
+
|
|
142
|
+
# An immutable start receipt survives interruption, and the immutable
|
|
143
|
+
# finish receipt records the actual outcome. No partial failure can be
|
|
144
|
+
# reported as completion or silently retried.
|
|
145
|
+
operation_id = secrets.token_hex(16)
|
|
146
|
+
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
147
|
+
base = {
|
|
148
|
+
"schemaVersion": 1, "operationId": operation_id,
|
|
149
|
+
"startedAt": stamp, "reviewedPlanSha256": expected_plan_sha256,
|
|
150
|
+
"projectId": config["declaration"]["projectId"],
|
|
151
|
+
"plannedCandidateNames": current["retentionCandidateNames"][:32],
|
|
152
|
+
}
|
|
153
|
+
prefix = config["status"] / ("auth-retention-" + operation_id)
|
|
154
|
+
_write_private_json(Path(str(prefix) + "-started.json"), base, replace=False)
|
|
155
|
+
try:
|
|
156
|
+
result = helper.apply_reviewed_disposable_retention(
|
|
157
|
+
config["backups"], config["lock"], config["declaration"], preview,
|
|
158
|
+
keep_last=envelope["keepLast"], environment="disposable",
|
|
159
|
+
authorize_disposable_delete=True, max_delete=32)
|
|
160
|
+
if (type(result) is not dict or
|
|
161
|
+
result.get("deletedRecoverySetNames") != base["plannedCandidateNames"] or
|
|
162
|
+
result.get("deletionPerformed") is not bool(base["plannedCandidateNames"])):
|
|
163
|
+
raise DeploymentKitError("Disposable retention deletion proof differs.")
|
|
164
|
+
except Exception:
|
|
165
|
+
_write_private_json(
|
|
166
|
+
Path(str(prefix) + "-failed.json"),
|
|
167
|
+
{**base, "completedAt": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
168
|
+
"outcome": "failed-or-unverified"}, replace=False)
|
|
169
|
+
raise DeploymentKitError(
|
|
170
|
+
"Disposable retention incomplete; inspect private receipts and inventory."
|
|
171
|
+
) from None
|
|
172
|
+
|
|
173
|
+
_write_private_json(
|
|
174
|
+
Path(str(prefix) + "-completed.json"),
|
|
175
|
+
{**base, "completedAt": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
176
|
+
"outcome": "success", "deletedRecoverySetNames": result["deletedRecoverySetNames"]},
|
|
177
|
+
replace=False)
|
|
178
|
+
return {
|
|
179
|
+
"deletedRecoverySetNames": result["deletedRecoverySetNames"],
|
|
180
|
+
"deletionPerformed": result["deletionPerformed"] is True,
|
|
181
|
+
"applicationRecoveryReady": False,
|
|
182
|
+
"scheduledBackupVerified": False,
|
|
183
|
+
"alertInboxReceiptVerified": False,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv: list[str] | None = None) -> int:
|
|
188
|
+
parser = argparse.ArgumentParser(
|
|
189
|
+
description="Manual disposable-only Auth retention; never a Production or timer command")
|
|
190
|
+
parser.add_argument("action", choices=("plan", "apply"))
|
|
191
|
+
parser.add_argument("--config", type=Path, required=True)
|
|
192
|
+
parser.add_argument("--keep-last", type=int, default=7)
|
|
193
|
+
parser.add_argument("--reviewed-plan-sha256")
|
|
194
|
+
parser.add_argument("--authorize-disposable-delete", action="store_true")
|
|
195
|
+
args = parser.parse_args(argv)
|
|
196
|
+
try:
|
|
197
|
+
config = load_disposable_operation_config(args.config)
|
|
198
|
+
if args.action == "plan":
|
|
199
|
+
if args.reviewed_plan_sha256 or args.authorize_disposable_delete:
|
|
200
|
+
raise DeploymentKitError("Read-only retention planning refuses deletion approval flags.")
|
|
201
|
+
result = create_reviewed_disposable_retention_plan(
|
|
202
|
+
config, keep_last=args.keep_last)
|
|
203
|
+
print("Disposable Auth retention candidate count: " + str(result["candidateCount"]))
|
|
204
|
+
print("Review private plan before apply; SHA-256: " + result["planSha256"])
|
|
205
|
+
else:
|
|
206
|
+
if args.keep_last != 7:
|
|
207
|
+
raise DeploymentKitError("Retention apply uses the keep-last value pinned in reviewed plan.")
|
|
208
|
+
result = apply_reviewed_disposable_retention(
|
|
209
|
+
config, expected_plan_sha256=args.reviewed_plan_sha256,
|
|
210
|
+
authorize_disposable_delete=args.authorize_disposable_delete)
|
|
211
|
+
print("Disposable Auth verified retention deleted sets: " +
|
|
212
|
+
str(len(result["deletedRecoverySetNames"])))
|
|
213
|
+
return 0
|
|
214
|
+
except DeploymentKitError:
|
|
215
|
+
print("BLOCKED: Disposable Auth retention could not be proven safe.", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
raise SystemExit(main())
|