@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.
- package/assets/authentication/distribution.json +90 -0
- package/assets/components.json +17 -3
- package/assets/core/distribution.json +12 -0
- package/assets/deployment/distribution.json +292 -68
- package/assets/generated/ASSEMBLY.json +150 -4
- 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/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,489 @@
|
|
|
1
|
+
"""Owner-private, disposable-only Auth backup operation orchestration.
|
|
2
|
+
|
|
3
|
+
A reviewed operator may invoke this from a separately installed systemd
|
|
4
|
+
oneshot/timer. Nothing in this module installs, enables, or dispatches units.
|
|
5
|
+
No Production backup, provider interaction, or Health/restore-ready claim.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from .auth_recovery_lifecycle import (
|
|
21
|
+
_binding_fingerprint,
|
|
22
|
+
_read_restart_journal,
|
|
23
|
+
_real_private_directory,
|
|
24
|
+
_separated,
|
|
25
|
+
_sync_private_directory,
|
|
26
|
+
capture_with_disposable_service_pause,
|
|
27
|
+
recover_interrupted_disposable_auth,
|
|
28
|
+
)
|
|
29
|
+
from .errors import DeploymentKitError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_STATUS = "auth-backup-status.json"
|
|
33
|
+
_ATTEMPTS = "attempts"
|
|
34
|
+
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
|
35
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
36
|
+
_ALLOWED = {
|
|
37
|
+
"schemaVersion", "environment", "projectRoot", "sourceDataRoot",
|
|
38
|
+
"backupsRoot", "lockDirectory", "statusDirectory",
|
|
39
|
+
"authRevision", "recoverySha256", "declaration", "dockerBinding",
|
|
40
|
+
}
|
|
41
|
+
_BINDING = {
|
|
42
|
+
"container_name", "expected_owner", "expected_environment",
|
|
43
|
+
"expected_image_digest", "expected_data_source",
|
|
44
|
+
"expected_data_destination", "owner_label", "environment_label",
|
|
45
|
+
"required_auth_environment",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _clock() -> datetime:
|
|
50
|
+
return datetime.now(timezone.utc)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _stamp(now: datetime) -> str:
|
|
54
|
+
if not isinstance(now, datetime) or now.tzinfo is None:
|
|
55
|
+
raise DeploymentKitError("UTC Auth backup clock required.")
|
|
56
|
+
return now.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _regular_private_file(path: Path, *, missing_ok: bool = False) -> bool:
|
|
60
|
+
for component in (path, *path.parents):
|
|
61
|
+
if component.is_symlink():
|
|
62
|
+
raise DeploymentKitError("Auth operation path contains a symlink.")
|
|
63
|
+
try:
|
|
64
|
+
info = path.lstat()
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
if missing_ok:
|
|
67
|
+
return False
|
|
68
|
+
raise DeploymentKitError("Private Auth operation file is unavailable.") from None
|
|
69
|
+
except OSError:
|
|
70
|
+
raise DeploymentKitError("Private Auth operation file cannot be inspected.") from None
|
|
71
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or
|
|
72
|
+
stat.S_IMODE(info.st_mode) & 0o077 or not 0 < info.st_size <= 64 * 1024):
|
|
73
|
+
raise DeploymentKitError("Private Auth operation file has unsafe size, mode or identity.")
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_json(path: Path) -> dict:
|
|
78
|
+
_regular_private_file(path)
|
|
79
|
+
expected = path.lstat()
|
|
80
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
81
|
+
try:
|
|
82
|
+
fd = os.open(path, flags)
|
|
83
|
+
with os.fdopen(fd, "r", encoding="utf-8") as handle:
|
|
84
|
+
observed = os.fstat(handle.fileno())
|
|
85
|
+
if ((observed.st_dev, observed.st_ino, observed.st_nlink) !=
|
|
86
|
+
(expected.st_dev, expected.st_ino, expected.st_nlink) or
|
|
87
|
+
not stat.S_ISREG(observed.st_mode) or
|
|
88
|
+
stat.S_IMODE(observed.st_mode) & 0o077 or
|
|
89
|
+
observed.st_size > 65536):
|
|
90
|
+
raise ValueError("private file changed during open")
|
|
91
|
+
text = handle.read(65537)
|
|
92
|
+
if len(text) > 65536:
|
|
93
|
+
raise ValueError("oversize")
|
|
94
|
+
def unique_pairs(pairs):
|
|
95
|
+
value = {}
|
|
96
|
+
for key, item in pairs:
|
|
97
|
+
if key in value:
|
|
98
|
+
raise ValueError("duplicate field")
|
|
99
|
+
value[key] = item
|
|
100
|
+
return value
|
|
101
|
+
value = json.loads(text, object_pairs_hook=unique_pairs)
|
|
102
|
+
except (OSError, ValueError, UnicodeError):
|
|
103
|
+
raise DeploymentKitError("Private Auth operation JSON is invalid.") from None
|
|
104
|
+
if type(value) is not dict:
|
|
105
|
+
raise DeploymentKitError("Private Auth operation JSON must be an object.")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _project_directory(path: Path) -> Path:
|
|
110
|
+
if not path.is_absolute():
|
|
111
|
+
raise DeploymentKitError("Reviewed Auth Project root must be absolute.")
|
|
112
|
+
for part in (path, *path.parents):
|
|
113
|
+
if part.is_symlink():
|
|
114
|
+
raise DeploymentKitError("Reviewed Auth Project root cannot contain symlinks.")
|
|
115
|
+
if not path.is_dir():
|
|
116
|
+
raise DeploymentKitError("Reviewed Auth Project root is unavailable.")
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_disposable_operation_config(path: Path) -> dict:
|
|
121
|
+
"""Parse a strict, protected, non-secret operator-reviewed config.
|
|
122
|
+
|
|
123
|
+
The actual Docker owner/image/mount, source revision and Auth declaration
|
|
124
|
+
must come from a separately reviewed generated-Project acceptance record.
|
|
125
|
+
The config is not that review or permission to touch Production.
|
|
126
|
+
"""
|
|
127
|
+
if sys.platform != "linux":
|
|
128
|
+
raise DeploymentKitError("Disposable Auth scheduled operations require Linux.")
|
|
129
|
+
path = Path(os.path.abspath(path))
|
|
130
|
+
_real_private_directory(path.parent)
|
|
131
|
+
raw = _read_json(path)
|
|
132
|
+
if set(raw) != _ALLOWED or type(raw.get("schemaVersion")) is not int or (
|
|
133
|
+
raw["schemaVersion"] != 1 or raw["environment"] != "disposable"):
|
|
134
|
+
raise DeploymentKitError("Only exact disposable Auth operation configuration is supported.")
|
|
135
|
+
fields = ("projectRoot", "sourceDataRoot", "backupsRoot", "lockDirectory", "statusDirectory")
|
|
136
|
+
if any(type(raw.get(key)) is not str or not Path(raw[key]).is_absolute()
|
|
137
|
+
for key in fields):
|
|
138
|
+
raise DeploymentKitError("Auth operation paths must be absolute.")
|
|
139
|
+
project = _project_directory(Path(raw["projectRoot"]))
|
|
140
|
+
source = _real_private_directory(Path(raw["sourceDataRoot"]))
|
|
141
|
+
backups = _real_private_directory(Path(raw["backupsRoot"]))
|
|
142
|
+
lock = _real_private_directory(Path(raw["lockDirectory"]))
|
|
143
|
+
status = _real_private_directory(Path(raw["statusDirectory"]))
|
|
144
|
+
_real_private_directory(status / _ATTEMPTS) # Preflight before any Docker stop.
|
|
145
|
+
paths = (project, source, backups, lock, status)
|
|
146
|
+
if any(not _separated(one, two) for i, one in enumerate(paths)
|
|
147
|
+
for two in paths[i + 1:]):
|
|
148
|
+
raise DeploymentKitError("Auth operation directories must be separate.")
|
|
149
|
+
if (not isinstance(raw.get("authRevision"), str) or
|
|
150
|
+
_HEX40.fullmatch(raw["authRevision"]) is None or
|
|
151
|
+
not isinstance(raw.get("recoverySha256"), str) or
|
|
152
|
+
_HEX64.fullmatch(raw["recoverySha256"]) is None or
|
|
153
|
+
type(raw.get("declaration")) is not dict):
|
|
154
|
+
raise DeploymentKitError("Auth recovery source or declaration is not exact.")
|
|
155
|
+
binding = raw.get("dockerBinding")
|
|
156
|
+
if type(binding) is not dict or set(binding) != _BINDING:
|
|
157
|
+
raise DeploymentKitError("Exact reviewed disposable Docker binding is required.")
|
|
158
|
+
if (binding.get("expected_environment") != "disposable" or
|
|
159
|
+
type(binding.get("expected_data_source")) is not str or
|
|
160
|
+
Path(binding["expected_data_source"]) != source):
|
|
161
|
+
raise DeploymentKitError("Auth operation container/data owner does not match.")
|
|
162
|
+
binding = {**binding, "expected_data_source": source}
|
|
163
|
+
if (type(binding["required_auth_environment"]) is not dict or
|
|
164
|
+
set(binding["required_auth_environment"]) != {
|
|
165
|
+
"ProjectForge__Authentication__DbPath",
|
|
166
|
+
"ProjectForge__Authentication__DataProtectionKeysPath",
|
|
167
|
+
}):
|
|
168
|
+
raise DeploymentKitError("Auth operation may only pin canonical DB and key-ring mounts.")
|
|
169
|
+
_binding_fingerprint(binding)
|
|
170
|
+
return {
|
|
171
|
+
"project": project, "source": source, "backups": backups, "lock": lock,
|
|
172
|
+
"status": status, "revision": raw["authRevision"],
|
|
173
|
+
"helperSha": raw["recoverySha256"],
|
|
174
|
+
"declaration": raw["declaration"], "binding": binding,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@contextmanager
|
|
179
|
+
def _operation_lock(directory: Path):
|
|
180
|
+
import fcntl
|
|
181
|
+
|
|
182
|
+
directory = _real_private_directory(directory)
|
|
183
|
+
file = directory / "auth-operation.lock"
|
|
184
|
+
try:
|
|
185
|
+
fd = os.open(file, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
186
|
+
except OSError:
|
|
187
|
+
raise DeploymentKitError("Auth operation lock cannot be safely opened.") from None
|
|
188
|
+
try:
|
|
189
|
+
info = os.fstat(fd)
|
|
190
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or
|
|
191
|
+
stat.S_IMODE(info.st_mode) & 0o077):
|
|
192
|
+
raise DeploymentKitError("Auth operation lock is not private.")
|
|
193
|
+
try:
|
|
194
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
195
|
+
except BlockingIOError:
|
|
196
|
+
raise DeploymentKitError("Another Auth backup operation is running.") from None
|
|
197
|
+
yield
|
|
198
|
+
finally:
|
|
199
|
+
os.close(fd)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _write_private_json(path: Path, value: dict, *, replace: bool) -> None:
|
|
203
|
+
directory = _real_private_directory(path.parent)
|
|
204
|
+
if replace:
|
|
205
|
+
_regular_private_file(path, missing_ok=True)
|
|
206
|
+
elif path.exists() or path.is_symlink():
|
|
207
|
+
raise DeploymentKitError("Refusing existing immutable Auth attempt receipt.")
|
|
208
|
+
blob = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
209
|
+
if len(blob) > 16 * 1024:
|
|
210
|
+
raise DeploymentKitError("Auth operation receipt exceeds safe size.")
|
|
211
|
+
temp = directory / ("." + secrets.token_hex(16) + ".tmp")
|
|
212
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL |
|
|
213
|
+
getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
214
|
+
try:
|
|
215
|
+
with os.fdopen(fd, "wb") as handle:
|
|
216
|
+
handle.write(blob)
|
|
217
|
+
handle.flush()
|
|
218
|
+
os.fsync(handle.fileno())
|
|
219
|
+
if replace:
|
|
220
|
+
os.replace(temp, path)
|
|
221
|
+
else:
|
|
222
|
+
# Atomic no-overwrite publication; source temp remains private.
|
|
223
|
+
os.link(temp, path, follow_symlinks=False)
|
|
224
|
+
_sync_private_directory(directory)
|
|
225
|
+
except OSError:
|
|
226
|
+
raise DeploymentKitError("Private Auth operation receipt could not be committed.") from None
|
|
227
|
+
finally:
|
|
228
|
+
if temp.exists():
|
|
229
|
+
temp.unlink()
|
|
230
|
+
_sync_private_directory(directory)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _valid_utc_timestamp(value: object) -> bool:
|
|
234
|
+
if type(value) is not str or not value.endswith("Z"):
|
|
235
|
+
return False
|
|
236
|
+
try:
|
|
237
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
238
|
+
except ValueError:
|
|
239
|
+
return False
|
|
240
|
+
return parsed.utcoffset() == timedelta(0)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _validate_status(value: dict) -> None:
|
|
244
|
+
if (set(value) != {"schemaVersion", "lastAttempt", "lastSuccess", "lastResumption"}
|
|
245
|
+
or type(value["schemaVersion"]) is not int or value["schemaVersion"] != 1):
|
|
246
|
+
raise DeploymentKitError("Auth operation status schema is invalid.")
|
|
247
|
+
attempt = value["lastAttempt"]
|
|
248
|
+
if attempt is not None:
|
|
249
|
+
if (type(attempt) is not dict or set(attempt) != {
|
|
250
|
+
"id", "setName", "startedAt", "completedAt", "outcome",
|
|
251
|
+
"backupArtifactIntegrityVerified", "runtimeResumedVerified",
|
|
252
|
+
"applicationRecoveryReady", "healthVerified",
|
|
253
|
+
"alertInboxReceiptVerified",
|
|
254
|
+
} or type(attempt["id"]) is not str or
|
|
255
|
+
re.fullmatch(r"[0-9a-f]{32}", attempt["id"]) is None or
|
|
256
|
+
type(attempt["setName"]) is not str or
|
|
257
|
+
re.fullmatch(r"20[0-9]{6}T[0-9]{6}Z-[0-9a-f]{32}",
|
|
258
|
+
attempt["setName"]) is None or
|
|
259
|
+
not _valid_utc_timestamp(attempt["startedAt"]) or
|
|
260
|
+
type(attempt["outcome"]) is not str or
|
|
261
|
+
attempt["outcome"] not in {"in-progress", "success", "failed"} or
|
|
262
|
+
any(type(attempt[key]) is not bool for key in (
|
|
263
|
+
"backupArtifactIntegrityVerified", "runtimeResumedVerified",
|
|
264
|
+
"applicationRecoveryReady", "healthVerified",
|
|
265
|
+
"alertInboxReceiptVerified")) or
|
|
266
|
+
attempt["applicationRecoveryReady"] is not False or
|
|
267
|
+
attempt["healthVerified"] is not False or
|
|
268
|
+
attempt["alertInboxReceiptVerified"] is not False or
|
|
269
|
+
(attempt["completedAt"] is None) !=
|
|
270
|
+
(attempt["outcome"] == "in-progress") or
|
|
271
|
+
(attempt["completedAt"] is not None and
|
|
272
|
+
not _valid_utc_timestamp(attempt["completedAt"]))):
|
|
273
|
+
raise DeploymentKitError("Auth attempt receipt is malformed.")
|
|
274
|
+
success = value["lastSuccess"]
|
|
275
|
+
if success is not None:
|
|
276
|
+
if (type(success) is not dict or
|
|
277
|
+
set(success) != {"setName", "completedAt"} or
|
|
278
|
+
type(success["setName"]) is not str or
|
|
279
|
+
re.fullmatch(r"20[0-9]{6}T[0-9]{6}Z-[0-9a-f]{32}",
|
|
280
|
+
success["setName"]) is None or
|
|
281
|
+
not _valid_utc_timestamp(success["completedAt"])):
|
|
282
|
+
raise DeploymentKitError("Auth last-success evidence is malformed.")
|
|
283
|
+
resumption = value["lastResumption"]
|
|
284
|
+
if resumption is not None:
|
|
285
|
+
if (type(resumption) is not dict or
|
|
286
|
+
set(resumption) != {"checkedAt", "state", "runtimeResumedVerified"} or
|
|
287
|
+
not _valid_utc_timestamp(resumption["checkedAt"]) or
|
|
288
|
+
type(resumption["state"]) is not str or
|
|
289
|
+
resumption["state"] not in {
|
|
290
|
+
"failed", "no-pending-restart", "original-disposable-running",
|
|
291
|
+
} or type(resumption["runtimeResumedVerified"]) is not bool):
|
|
292
|
+
raise DeploymentKitError("Auth last-resumption evidence is malformed.")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _previous_status(directory: Path) -> dict:
|
|
296
|
+
path = directory / _STATUS
|
|
297
|
+
if not _regular_private_file(path, missing_ok=True):
|
|
298
|
+
return {"schemaVersion": 1, "lastAttempt": None, "lastSuccess": None,
|
|
299
|
+
"lastResumption": None}
|
|
300
|
+
value = _read_json(path)
|
|
301
|
+
_validate_status(value)
|
|
302
|
+
return value
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _commit_status(config: dict, value: dict) -> None:
|
|
306
|
+
_write_private_json(config["status"] / _STATUS, value, replace=True)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _receipt(config: dict, attempt: dict) -> None:
|
|
310
|
+
root = config["status"] / _ATTEMPTS
|
|
311
|
+
_real_private_directory(root)
|
|
312
|
+
_write_private_json(root / (attempt["id"] + ".json"), attempt, replace=False)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def run_reviewed_disposable_backup(
|
|
316
|
+
config: dict, *, authorize_disposable_stop: bool = False,
|
|
317
|
+
now: datetime | None = None,
|
|
318
|
+
) -> dict:
|
|
319
|
+
"""One explicitly authorized operation, suitable for a reviewed one-shot.
|
|
320
|
+
|
|
321
|
+
A systemd timer is never installed or enabled by calling this. Success
|
|
322
|
+
means capture integrity + original Docker ID resumed, not application
|
|
323
|
+
health, full recovery, provider or alert inbox proof.
|
|
324
|
+
"""
|
|
325
|
+
if sys.platform != "linux" or authorize_disposable_stop is not True:
|
|
326
|
+
raise DeploymentKitError("Explicit disposable Auth backup authorization required.")
|
|
327
|
+
with _operation_lock(config["status"]):
|
|
328
|
+
status = _previous_status(config["status"])
|
|
329
|
+
started = _clock() if now is None else now
|
|
330
|
+
attempt_id = secrets.token_hex(16)
|
|
331
|
+
name = started.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + attempt_id
|
|
332
|
+
attempt = {"id": attempt_id, "setName": name, "startedAt": _stamp(started),
|
|
333
|
+
"completedAt": None, "outcome": "in-progress",
|
|
334
|
+
"backupArtifactIntegrityVerified": False,
|
|
335
|
+
"runtimeResumedVerified": False,
|
|
336
|
+
"applicationRecoveryReady": False,
|
|
337
|
+
"healthVerified": False, "alertInboxReceiptVerified": False}
|
|
338
|
+
status["lastAttempt"] = attempt
|
|
339
|
+
_commit_status(config, status) # Durable BEFORE stopping the container.
|
|
340
|
+
try:
|
|
341
|
+
capture = capture_with_disposable_service_pause(
|
|
342
|
+
project_root=config["project"], source_data_root=config["source"],
|
|
343
|
+
destination=config["backups"] / name, lock_directory=config["lock"],
|
|
344
|
+
declaration=config["declaration"],
|
|
345
|
+
reviewed_auth_revision=config["revision"],
|
|
346
|
+
reviewed_recovery_sha256=config["helperSha"],
|
|
347
|
+
reviewed_docker_binding=config["binding"],
|
|
348
|
+
authorize_disposable_stop=True,
|
|
349
|
+
)
|
|
350
|
+
if (capture.get("backupArtifactIntegrityVerified") is not True or
|
|
351
|
+
capture.get("runtimeResumedVerified") is not True):
|
|
352
|
+
raise DeploymentKitError("Auth backup integrity or Docker resumption not proven.")
|
|
353
|
+
attempt["backupArtifactIntegrityVerified"] = True
|
|
354
|
+
attempt["runtimeResumedVerified"] = True
|
|
355
|
+
attempt["outcome"] = "success"
|
|
356
|
+
except Exception:
|
|
357
|
+
# Do not write raw Docker/Auth messages (or credentials) to status.
|
|
358
|
+
attempt["outcome"] = "failed"
|
|
359
|
+
attempt["completedAt"] = _stamp(_clock() if now is None else now)
|
|
360
|
+
_receipt(config, attempt)
|
|
361
|
+
status["lastAttempt"] = attempt
|
|
362
|
+
if attempt["outcome"] == "success":
|
|
363
|
+
status["lastSuccess"] = {
|
|
364
|
+
"setName": name, "completedAt": attempt["completedAt"]}
|
|
365
|
+
# A later fully verified capture proves the exact original
|
|
366
|
+
# container resumed. Clear an older unverified/failed recovery
|
|
367
|
+
# signal only on this independent, journal-protected evidence.
|
|
368
|
+
status["lastResumption"] = {
|
|
369
|
+
"checkedAt": attempt["completedAt"],
|
|
370
|
+
"state": "original-disposable-running",
|
|
371
|
+
"runtimeResumedVerified": True,
|
|
372
|
+
}
|
|
373
|
+
_commit_status(config, status)
|
|
374
|
+
if attempt["outcome"] != "success":
|
|
375
|
+
raise DeploymentKitError(
|
|
376
|
+
"Disposable Auth backup failed; check the private restart journal and recover."
|
|
377
|
+
) from None
|
|
378
|
+
return attempt
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def run_reviewed_disposable_recovery(
|
|
382
|
+
config: dict, *, authorize_disposable_resume: bool = False,
|
|
383
|
+
) -> dict:
|
|
384
|
+
"""Independent rescue entrypoint after the capture worker exits/dies."""
|
|
385
|
+
if sys.platform != "linux" or authorize_disposable_resume is not True:
|
|
386
|
+
raise DeploymentKitError("Explicit disposable Auth recovery authorization required.")
|
|
387
|
+
with _operation_lock(config["status"]):
|
|
388
|
+
status = _previous_status(config["status"])
|
|
389
|
+
try:
|
|
390
|
+
result = recover_interrupted_disposable_auth(
|
|
391
|
+
lock_directory=config["lock"],
|
|
392
|
+
reviewed_docker_binding=config["binding"],
|
|
393
|
+
authorize_disposable_resume=True)
|
|
394
|
+
except Exception:
|
|
395
|
+
status["lastResumption"] = {
|
|
396
|
+
"checkedAt": _stamp(_clock()), "state": "failed",
|
|
397
|
+
"runtimeResumedVerified": False}
|
|
398
|
+
_commit_status(config, status)
|
|
399
|
+
raise DeploymentKitError(
|
|
400
|
+
"Exact original Auth Docker recovery could not be proven."
|
|
401
|
+
) from None
|
|
402
|
+
status["lastResumption"] = {
|
|
403
|
+
"checkedAt": _stamp(_clock()),
|
|
404
|
+
"state": result["state"],
|
|
405
|
+
"runtimeResumedVerified": result["runtimeResumedVerified"] is True}
|
|
406
|
+
_commit_status(config, status)
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def inspect_disposable_backup_status(config: dict, *, now: datetime | None = None,
|
|
411
|
+
max_age_hours: int = 48) -> dict:
|
|
412
|
+
"""Read-only alert candidate summary; does NOT send an alert."""
|
|
413
|
+
if type(max_age_hours) is not int or not 1 <= max_age_hours <= 720:
|
|
414
|
+
raise DeploymentKitError("Auth backup maximum age is unsupported.")
|
|
415
|
+
status = _previous_status(config["status"])
|
|
416
|
+
journal = _read_restart_journal(config["lock"], config["binding"])
|
|
417
|
+
current = _clock() if now is None else now
|
|
418
|
+
previous = status["lastAttempt"]
|
|
419
|
+
success = status["lastSuccess"]
|
|
420
|
+
if journal is not None:
|
|
421
|
+
state = "recovery-required"
|
|
422
|
+
elif (status["lastResumption"] is not None and
|
|
423
|
+
status["lastResumption"]["state"] == "failed"):
|
|
424
|
+
# Even if the last backup was valid, an unresolved exact-Docker
|
|
425
|
+
# resumption failure is a host operation failure, never fresh.
|
|
426
|
+
state = "recovery-failed"
|
|
427
|
+
elif (status["lastResumption"] is not None and
|
|
428
|
+
status["lastResumption"]["runtimeResumedVerified"] is not True):
|
|
429
|
+
# "No pending restart" does NOT inspect/prove a running container.
|
|
430
|
+
# It must not silently clear an earlier failed recovery.
|
|
431
|
+
state = "recovery-unverified"
|
|
432
|
+
elif previous is not None and previous.get("outcome") == "failed":
|
|
433
|
+
state = "backup-failed"
|
|
434
|
+
elif previous is not None and previous.get("outcome") == "in-progress":
|
|
435
|
+
state = "interrupted-or-running"
|
|
436
|
+
elif success is None:
|
|
437
|
+
state = "no-backup"
|
|
438
|
+
else:
|
|
439
|
+
try:
|
|
440
|
+
at = datetime.fromisoformat(success["completedAt"].replace("Z", "+00:00"))
|
|
441
|
+
state = ("fresh" if at <= current <= at + timedelta(hours=max_age_hours)
|
|
442
|
+
else "overdue")
|
|
443
|
+
except (TypeError, KeyError, ValueError):
|
|
444
|
+
raise DeploymentKitError("Private Auth success timestamp is invalid.") from None
|
|
445
|
+
return {
|
|
446
|
+
"state": state, "lastAttempt": previous,
|
|
447
|
+
"lastSuccess": success, "lastResumption": status["lastResumption"],
|
|
448
|
+
"scheduledBackupVerified": False,
|
|
449
|
+
"healthVerified": False,
|
|
450
|
+
"alertInboxReceiptVerified": False,
|
|
451
|
+
"applicationRecoveryReady": False,
|
|
452
|
+
"mutated": False,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def main(argv: list[str] | None = None) -> int:
|
|
457
|
+
parser = argparse.ArgumentParser(description="Disposable Auth Kit backup runner (not Production)")
|
|
458
|
+
parser.add_argument("action", choices=["backup", "recover", "status"])
|
|
459
|
+
parser.add_argument("--config", required=True, type=Path)
|
|
460
|
+
parser.add_argument("--authorize-disposable-stop", action="store_true")
|
|
461
|
+
parser.add_argument("--authorize-disposable-resume", action="store_true")
|
|
462
|
+
args = parser.parse_args(argv)
|
|
463
|
+
try:
|
|
464
|
+
config = load_disposable_operation_config(args.config)
|
|
465
|
+
if args.action == "backup":
|
|
466
|
+
result = run_reviewed_disposable_backup(
|
|
467
|
+
config, authorize_disposable_stop=args.authorize_disposable_stop)
|
|
468
|
+
print("Disposable Auth backup: verified artifact; original Docker running.")
|
|
469
|
+
print("Set: " + result["setName"])
|
|
470
|
+
elif args.action == "recover":
|
|
471
|
+
result = run_reviewed_disposable_recovery(
|
|
472
|
+
config, authorize_disposable_resume=args.authorize_disposable_resume)
|
|
473
|
+
print("Disposable Auth original Docker recovery state: " + result["state"])
|
|
474
|
+
else:
|
|
475
|
+
state = inspect_disposable_backup_status(config)["state"]
|
|
476
|
+
print("Disposable Auth operation: " + state)
|
|
477
|
+
# A scheduler/monitor must not mistake stale/failed backups or
|
|
478
|
+
# pending exact-Docker recovery for an operational success.
|
|
479
|
+
# No alert is sent; nonzero status is an observable host signal.
|
|
480
|
+
if state != "fresh":
|
|
481
|
+
return 2
|
|
482
|
+
return 0
|
|
483
|
+
except DeploymentKitError:
|
|
484
|
+
print("BLOCKED: Disposable Auth backup operation cannot be verified.", file=sys.stderr)
|
|
485
|
+
return 2
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
if __name__ == "__main__":
|
|
489
|
+
raise SystemExit(main())
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
4
4
|
import readline from 'node:readline/promises';
|
|
5
5
|
import { stdin as input, stdout as output } from 'node:process';
|
|
6
6
|
import { applyBootstrapMigrations, planBootstrapMigrations } from './bootstrap-migrations.mjs';
|
|
7
|
-
import { applyProfile, checkProfile, loadProjectForgeBaseline, planProfile } from './engine.mjs';
|
|
7
|
+
import { applyProfile, applyRepair, checkProfile, loadProjectForgeBaseline, planProfile, planRepair } from './engine.mjs';
|
|
8
8
|
import { loadPackageData } from './package-data.mjs';
|
|
9
9
|
|
|
10
10
|
function parseArgs(argv) {
|
|
@@ -81,6 +81,7 @@ function usage(command) {
|
|
|
81
81
|
const commandUsage = {
|
|
82
82
|
install: 'project-forge install [--root PATH] [--profile NAME] [--apply|--yes] [--consumer NAME] [--consumer-baseline LABEL] [--accept-retired component:path]',
|
|
83
83
|
update: 'project-forge update [--root PATH] [--profile NAME] [--apply|--yes] [--accept-retired component:path]',
|
|
84
|
+
repair: 'project-forge repair [--root PATH] [--profile NAME] [--apply|--yes]',
|
|
84
85
|
status: 'project-forge status [--root PATH] [--profile NAME]',
|
|
85
86
|
check: 'project-forge check [--root PATH] [--profile NAME]',
|
|
86
87
|
version: 'project-forge version'
|
|
@@ -91,14 +92,14 @@ function usage(command) {
|
|
|
91
92
|
return;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
console.log(`@mriekki/project-forge\n\nCommands:\n install Preview/install bundled Project Forge assets\n update Preview/update to this package's bundled Project Forge assets\n status Show installed umbrella/component provenance\n check Verify managed files and baselines exactly match this package\n version Show package version\n\nCommon options:\n --root PATH\n --profile greenfield-project\n --apply | --yes\n --consumer NAME\n --consumer-baseline LABEL\n --accept-retired core:path\n -h, --help\n`);
|
|
95
|
+
console.log(`@mriekki/project-forge\n\nCommands:\n install Preview/install bundled Project Forge assets\n update Preview/update to this package's bundled Project Forge assets\n repair Restore missing managed files from exactly this installed package only\n status Show installed umbrella/component provenance\n check Verify managed files and baselines exactly match this package\n version Show package version\n\nCommon options:\n --root PATH\n --profile greenfield-project\n --apply | --yes\n --consumer NAME\n --consumer-baseline LABEL\n --accept-retired core:path\n -h, --help\n`);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
export async function run(argv = process.argv.slice(2), packageData = loadPackageData()) {
|
|
98
99
|
const { command, options } = parseArgs(argv);
|
|
99
100
|
if (command === 'help') { usage(); return 0; }
|
|
100
101
|
if (options.help) {
|
|
101
|
-
if (!['install', 'update', 'status', 'check', 'version'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
102
|
+
if (!['install', 'update', 'repair', 'status', 'check', 'version'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
102
103
|
usage(command);
|
|
103
104
|
return 0;
|
|
104
105
|
}
|
|
@@ -137,6 +138,34 @@ export async function run(argv = process.argv.slice(2), packageData = loadPackag
|
|
|
137
138
|
return clean ? 0 : 1;
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
if (command === 'repair') {
|
|
142
|
+
if (!Object.keys(installed).length) throw new Error('No Project Forge baseline; install first.');
|
|
143
|
+
if (installed.profile !== profile) throw new Error('Repair must use the exact installed profile.');
|
|
144
|
+
const bootstrap = planBootstrapMigrations(packageData, root);
|
|
145
|
+
const plan = planRepair(packageData, profile, root);
|
|
146
|
+
console.log(`Project Forge repair: ${plan.missing.length} missing managed file(s)`);
|
|
147
|
+
for (const file of plan.missing) console.log(` MISSING ${file.component}: ${file.path}`);
|
|
148
|
+
for (const blocker of plan.blockers) console.log(` BLOCKED: ${blocker}`);
|
|
149
|
+
if (bootstrap.rows.length || bootstrap.unsafe.length || !plan.safe) {
|
|
150
|
+
console.log('Refusing repair: stale package, migration, local edits, or conflicting files. No changes made.');
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
if (plan.missing.length === 0) {
|
|
154
|
+
console.log('Project Forge repair: already complete; no changes made.');
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
if (!await shouldApply(options)) {
|
|
158
|
+
console.log('Preview only. Re-run with --apply after reviewing missing managed files.');
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
applyRepair(packageData, profile, root);
|
|
162
|
+
if (!checkProfile(packageData, profile, root).clean) {
|
|
163
|
+
throw new Error('Repair completed but final managed-file verification is not clean.');
|
|
164
|
+
}
|
|
165
|
+
console.log(`Project Forge repair: PASS (${packageData.pkg.name}@${packageData.pkg.version})`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
140
169
|
if (!['install', 'update'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
141
170
|
if (command === 'install' && Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json already exists; use update instead of install');
|
|
142
171
|
if (command === 'update' && !Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json is missing; use install first');
|