@mriekki/project-forge 4.1.0 → 4.2.0

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.
@@ -14,7 +14,8 @@ from .doctor import target_doctor, workstation_doctor
14
14
  from .environment_probe import plan_environment_proof
15
15
  from .errors import DeploymentKitError
16
16
  from .profiles import load_profile
17
- from .ssh import verify_access
17
+ from .proof_receipt import load_environment_proof_receipt
18
+ from .ssh import diagnose_access
18
19
 
19
20
 
20
21
  READINESS_ORDER = (
@@ -34,6 +35,13 @@ def _phase(label: str, state: str, *, detail: Any = None) -> dict[str, Any]:
34
35
  return value
35
36
 
36
37
 
38
+ def _current_phase(phases: dict[str, dict[str, Any]]) -> str:
39
+ return next(
40
+ (key for key in READINESS_ORDER if phases[key]["state"] not in {"ready", "proven", "not-part-of-setup"}),
41
+ "application",
42
+ )
43
+
44
+
37
45
  def remote_setup_status(name: str) -> dict[str, Any]:
38
46
  """Return one read-only, terminal status model for remote environment setup."""
39
47
 
@@ -57,19 +65,15 @@ def remote_setup_status(name: str) -> dict[str, Any]:
57
65
  "application": _phase("APPLICATION DEPLOYED", "not-part-of-setup"),
58
66
  }
59
67
 
60
- target_access: dict[str, Any] | None = None
61
68
  host_platform: dict[str, Any] | None = None
62
69
 
63
70
  if target["state"] == "ready-to-verify":
64
- try:
65
- target_access = verify_access(load_profile(name))
66
- phases["targetAccess"] = _phase("TARGET ACCESS READY", "ready", detail=target_access)
67
- except DeploymentKitError as error:
68
- phases["targetAccess"] = _phase(
69
- "TARGET ACCESS READY",
70
- "verification-failed",
71
- detail={"reason": str(error)},
72
- )
71
+ target_access = diagnose_access(load_profile(name))
72
+ phases["targetAccess"] = _phase(
73
+ "TARGET ACCESS READY",
74
+ "ready" if target_access["state"] == "verified" else "diagnosis-required",
75
+ detail=target_access,
76
+ )
73
77
  elif target["state"] == "needs-profile":
74
78
  phases["targetAccess"] = _phase("TARGET ACCESS READY", "blocked-by-target-definition")
75
79
  else:
@@ -96,19 +100,76 @@ def remote_setup_status(name: str) -> dict[str, Any]:
96
100
  else:
97
101
  phases["hostPlatform"] = _phase("HOST PLATFORM READY", "blocked-by-target-access")
98
102
 
99
- current = next(
100
- (key for key in READINESS_ORDER if phases[key]["state"] not in {"ready", "not-part-of-setup"}),
101
- "application",
102
- )
103
103
  return {
104
104
  "targetName": name,
105
105
  "state": "host-platform-ready" if phases["hostPlatform"]["state"] == "ready" else "needs-setup",
106
- "currentPhase": current,
106
+ "currentPhase": _current_phase(phases),
107
107
  "phases": phases,
108
108
  "mutated": False,
109
109
  }
110
110
 
111
111
 
112
+ def _access_recovery(status: dict[str, Any]) -> dict[str, Any]:
113
+ detail = status["phases"]["targetAccess"].get("detail") or {}
114
+ category = detail.get("failureCategory")
115
+ target = status["targetName"]
116
+ command = f"project-forge-deploy environment setup status {target} --json"
117
+
118
+ if category in {"remote-authentication-rejected", "remote-account-policy-rejected"}:
119
+ return {
120
+ "kind": "operator-action-required",
121
+ "action": "inspect-remote-account-access-policy",
122
+ "command": command,
123
+ "reason": detail.get("guidance"),
124
+ "safety": (
125
+ "Use a trusted target console or already-authorized administrative session. "
126
+ "Do not reinstall keys repeatedly, enable password SSH, widen AllowUsers, or weaken host verification implicitly."
127
+ ),
128
+ }
129
+ if category == "local-signing-failed":
130
+ return {
131
+ "kind": "local-repair",
132
+ "action": "repair-local-key-signing",
133
+ "command": command,
134
+ "reason": detail.get("guidance"),
135
+ }
136
+ if category == "host-identity-rejected":
137
+ return {
138
+ "kind": "human-verification",
139
+ "action": "reverify-host-identity",
140
+ "command": f"project-forge-deploy target trust-candidates {target}",
141
+ "reason": detail.get("guidance"),
142
+ }
143
+ if category in {"transport-failed", "name-resolution-failed"}:
144
+ return {
145
+ "kind": "missing-facts",
146
+ "action": "repair-target-address-or-network-path",
147
+ "command": command,
148
+ "reason": detail.get("guidance") or detail.get("reason"),
149
+ }
150
+ if category == "missing-identity":
151
+ return {
152
+ "kind": "write-gated",
153
+ "action": "create-or-select-key",
154
+ "command": f"project-forge-deploy target key-create {target}",
155
+ "authorization": "[LOCAL KEY WRITE]",
156
+ "reason": detail.get("reason"),
157
+ }
158
+ if category == "missing-trust":
159
+ return {
160
+ "kind": "human-verification",
161
+ "action": "verify-and-enroll-host-trust",
162
+ "command": f"project-forge-deploy target trust-candidates {target}",
163
+ "reason": detail.get("reason"),
164
+ }
165
+ return {
166
+ "kind": "read-only",
167
+ "action": "diagnose-target-access",
168
+ "command": command,
169
+ "reason": detail.get("guidance") or detail.get("reason"),
170
+ }
171
+
172
+
112
173
  def _next_action(status: dict[str, Any]) -> dict[str, Any]:
113
174
  phases = status["phases"]
114
175
  target = phases["targetDefinition"].get("detail") or {}
@@ -151,12 +212,7 @@ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
151
212
  }
152
213
 
153
214
  if phases["targetAccess"]["state"] != "ready":
154
- return {
155
- "kind": "read-only",
156
- "action": "verify-target-access",
157
- "command": f"project-forge-deploy target verify {status['targetName']}",
158
- "reason": (phases["targetAccess"].get("detail") or {}).get("reason"),
159
- }
215
+ return _access_recovery(status)
160
216
 
161
217
  platform = phases["hostPlatform"].get("detail") or {}
162
218
  platform_state = phases["hostPlatform"]["state"]
@@ -188,6 +244,14 @@ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
188
244
  "reason": f"Host platform state is {platform_state}.",
189
245
  }
190
246
 
247
+ if phases["deploymentPath"]["state"] == "proven":
248
+ return {
249
+ "kind": "complete",
250
+ "action": "remote-environment-ready",
251
+ "reason": "Fresh workstation-local proof evidence matches this Project/environment/target context.",
252
+ "proofReceipt": phases["deploymentPath"].get("detail"),
253
+ }
254
+
191
255
  return {
192
256
  "kind": "proof-context-required",
193
257
  "action": "plan-deployment-path-proof",
@@ -204,7 +268,26 @@ def remote_setup_plan(
204
268
  ) -> dict[str, Any]:
205
269
  """Return the exact next deterministic setup action without mutating state."""
206
270
 
271
+ if (owner_repo is None) != (environment is None):
272
+ raise DeploymentKitError("ownerRepo and environment must be supplied together for setup proof planning.")
273
+
207
274
  status = remote_setup_status(name)
275
+ if owner_repo is not None and environment is not None and status["phases"]["hostPlatform"]["state"] == "ready":
276
+ receipt = load_environment_proof_receipt(
277
+ name,
278
+ owner_repo=owner_repo,
279
+ environment=environment,
280
+ production=production,
281
+ )
282
+ status["phases"]["deploymentPath"] = _phase(
283
+ "DEPLOYMENT PATH PROVEN",
284
+ "proven" if receipt["state"] == "proven" else "not-proven",
285
+ detail=receipt,
286
+ )
287
+ status["currentPhase"] = _current_phase(status["phases"])
288
+ if receipt["state"] == "proven":
289
+ status["state"] = "environment-ready"
290
+
208
291
  next_action = _next_action(status)
209
292
 
210
293
  if (
@@ -230,9 +313,8 @@ def remote_setup_plan(
230
313
  "command": command,
231
314
  "proofPlan": proof,
232
315
  "runAuthorization": proof["requiredAuthorization"],
316
+ "previousProof": status["phases"]["deploymentPath"].get("detail"),
233
317
  }
234
- elif (owner_repo is None) != (environment is None):
235
- raise DeploymentKitError("ownerRepo and environment must be supplied together for setup proof planning.")
236
318
 
237
319
  return {
238
320
  **status,
@@ -14,7 +14,7 @@ from .keys import key_fingerprint, public_key_info, required_tool as required_ss
14
14
  from .portable_profile import _machine_paths
15
15
  from .profiles import load_profile, save_profile, target_profile_path, validate_profile, validate_target_name
16
16
  from .security import check_identity_security, prepare_ssh_directory, validate_target_trust_file
17
- from .ssh import verify_access
17
+ from .ssh import probe_authentication_methods, resolve_addresses, verify_access
18
18
 
19
19
 
20
20
  HOME_LAB_BOOTSTRAP_PHRASE = "[HOME LAB ACCESS BOOTSTRAP]"
@@ -212,6 +212,23 @@ def _password_bootstrap_args(profile: dict[str, Any], *, first_use: bool, comman
212
212
  ]
213
213
 
214
214
 
215
+ def _require_password_bootstrap_capability(profile: dict[str, Any]) -> list[dict[str, Any]]:
216
+ """Fail before password onboarding when a trusted server advertises key-only auth."""
217
+
218
+ evidence = [probe_authentication_methods(profile, address) for address in resolve_addresses(profile)]
219
+ observed = [item for item in evidence if item["observed"]]
220
+ if observed and not any(item["passwordSupported"] for item in observed):
221
+ advertised = sorted({method for item in observed for method in item["methods"]})
222
+ shown = ", ".join(advertised) if advertised else "none observed"
223
+ raise DeploymentKitError(
224
+ "Password-assisted Home Lab bootstrap is unavailable for this trusted target: "
225
+ f"the SSH server advertises {shown}, not password/keyboard-interactive authentication. "
226
+ "Use the Strict key-onboarding path and a trusted console or already-authorized session "
227
+ "for any required server-side key/account-policy change. Do not enable password SSH merely for bootstrap."
228
+ )
229
+ return evidence
230
+
231
+
215
232
  def _install_public_key_with_password(profile: dict[str, Any], *, first_use: bool) -> None:
216
233
  info = public_key_info(profile)
217
234
  encoded = base64.b64encode(info["publicKey"].encode("utf-8")).decode("ascii")
@@ -316,6 +333,10 @@ def apply_home_lab_bootstrap(
316
333
  except DeploymentKitError:
317
334
  pass
318
335
 
336
+ # Existing trust lets us discover the server's advertised auth methods safely.
337
+ # Avoid a guaranteed-to-fail password prompt against public-key-only servers.
338
+ _require_password_bootstrap_capability(desired)
339
+
319
340
  _install_public_key_with_password(desired, first_use=first_use)
320
341
  validate_target_trust_file(desired)
321
342
  evidence = verify_access(desired)
@@ -0,0 +1,183 @@
1
+ """Durable workstation-local receipts for disposable environment proofs.
2
+
3
+ Receipts are diagnostic/readiness evidence, not deployment authorization or a workflow
4
+ database. They contain no credentials or mutable application data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from . import __version__
18
+ from .errors import DeploymentKitError
19
+ from .keys import key_fingerprint
20
+ from .profiles import load_profile
21
+
22
+
23
+ SCHEMA_VERSION = 1
24
+ PROOF_CONTRACT_VERSION = 1
25
+ _ENV_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
26
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
27
+
28
+
29
+ def _root() -> Path:
30
+ configured = os.environ.get("PROJECT_FORGE_HOME")
31
+ return Path(configured).expanduser() if configured else Path.home() / ".project-forge"
32
+
33
+
34
+ def _receipt_path(owner_repo: str, environment: str) -> Path:
35
+ if _REPO_RE.fullmatch(owner_repo) is None:
36
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
37
+ if _ENV_RE.fullmatch(environment) is None:
38
+ raise DeploymentKitError("environment must use lowercase letters, digits, and internal hyphens.")
39
+ owner, repo = owner_repo.split("/", 1)
40
+ return _root() / "evidence" / "environment-proofs" / owner / repo / f"{environment}.json"
41
+
42
+
43
+ def _sha256_file(path: Path) -> str:
44
+ try:
45
+ data = path.read_bytes()
46
+ except OSError as error:
47
+ raise DeploymentKitError(f"Cannot read target trust evidence {path}: {error}") from error
48
+ return hashlib.sha256(data).hexdigest()
49
+
50
+
51
+ def current_target_signature(target_name: str) -> dict[str, Any]:
52
+ """Return non-secret workstation/target identity used to invalidate stale proof."""
53
+
54
+ profile = load_profile(target_name)
55
+ identity = Path(profile["identityFile"])
56
+ known_hosts = Path(profile["knownHostsFile"])
57
+ signature = {
58
+ "targetName": profile["targetName"],
59
+ "targetOs": profile["targetOs"],
60
+ "hostName": profile["hostName"],
61
+ "sshAlias": profile["sshAlias"],
62
+ "user": profile["user"],
63
+ "port": profile["port"],
64
+ "addressOverride": profile["addressOverride"],
65
+ "identityFingerprint": key_fingerprint(identity),
66
+ "trustSha256": _sha256_file(known_hosts),
67
+ }
68
+ canonical = json.dumps(signature, sort_keys=True, separators=(",", ":")).encode("utf-8")
69
+ return {**signature, "signatureSha256": hashlib.sha256(canonical).hexdigest()}
70
+
71
+
72
+ def write_environment_proof_receipt(result: dict[str, Any]) -> dict[str, Any]:
73
+ required = {
74
+ "ownerRepo",
75
+ "environment",
76
+ "targetName",
77
+ "production",
78
+ "proof",
79
+ "cleanupState",
80
+ "finalMutationState",
81
+ }
82
+ missing = sorted(required.difference(result))
83
+ if missing:
84
+ raise DeploymentKitError("Environment proof result is missing receipt fields: " + ", ".join(missing))
85
+
86
+ path = _receipt_path(result["ownerRepo"], result["environment"])
87
+ target = current_target_signature(result["targetName"])
88
+ receipt = {
89
+ "schemaVersion": SCHEMA_VERSION,
90
+ "proofContractVersion": PROOF_CONTRACT_VERSION,
91
+ "deploymentKitVersion": __version__,
92
+ "recordedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
93
+ "ownerRepo": result["ownerRepo"],
94
+ "environment": result["environment"],
95
+ "targetName": result["targetName"],
96
+ "production": bool(result["production"]),
97
+ "proof": result["proof"],
98
+ "cleanupState": result["cleanupState"],
99
+ "finalMutationState": result["finalMutationState"],
100
+ "registryDigest": result.get("registryDigest"),
101
+ "localImageId": result.get("localImageId"),
102
+ "remoteImageId": result.get("remoteImageId"),
103
+ "targetSignature": target,
104
+ }
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ temp = path.with_suffix(".tmp")
107
+ temp.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
108
+ temp.replace(path)
109
+ return {"path": str(path), "recordedAt": receipt["recordedAt"], "state": "recorded"}
110
+
111
+
112
+ def load_environment_proof_receipt(
113
+ target_name: str,
114
+ *,
115
+ owner_repo: str,
116
+ environment: str,
117
+ production: bool,
118
+ ) -> dict[str, Any]:
119
+ """Return whether a prior proof receipt is still applicable on this workstation."""
120
+
121
+ path = _receipt_path(owner_repo, environment)
122
+ if not path.is_file():
123
+ return {"state": "missing", "path": str(path), "mutated": False}
124
+ try:
125
+ receipt = json.loads(path.read_text(encoding="utf-8"))
126
+ except (OSError, json.JSONDecodeError) as error:
127
+ return {
128
+ "state": "invalid",
129
+ "path": str(path),
130
+ "reason": f"Cannot read proof receipt: {error}",
131
+ "mutated": False,
132
+ }
133
+
134
+ expected = {
135
+ "schemaVersion": SCHEMA_VERSION,
136
+ "proofContractVersion": PROOF_CONTRACT_VERSION,
137
+ "deploymentKitVersion": __version__,
138
+ "ownerRepo": owner_repo,
139
+ "environment": environment,
140
+ "targetName": target_name,
141
+ "production": bool(production),
142
+ }
143
+ mismatches = [key for key, value in expected.items() if receipt.get(key) != value]
144
+ if mismatches:
145
+ return {
146
+ "state": "stale",
147
+ "path": str(path),
148
+ "reason": "Receipt context changed: " + ", ".join(mismatches),
149
+ "mutated": False,
150
+ }
151
+
152
+ if receipt.get("proof") != "passed" or receipt.get("cleanupState") not in {"removed", "absent"}:
153
+ return {
154
+ "state": "not-proven",
155
+ "path": str(path),
156
+ "reason": "Latest receipt is not a clean passing proof.",
157
+ "mutated": False,
158
+ }
159
+
160
+ try:
161
+ current = current_target_signature(target_name)
162
+ except DeploymentKitError as error:
163
+ return {"state": "stale", "path": str(path), "reason": str(error), "mutated": False}
164
+ previous = receipt.get("targetSignature") or {}
165
+ if previous.get("signatureSha256") != current.get("signatureSha256"):
166
+ return {
167
+ "state": "stale",
168
+ "path": str(path),
169
+ "reason": "Target/workstation identity or trust changed after the recorded proof.",
170
+ "mutated": False,
171
+ }
172
+
173
+ return {
174
+ "state": "proven",
175
+ "path": str(path),
176
+ "recordedAt": receipt.get("recordedAt"),
177
+ "targetName": target_name,
178
+ "ownerRepo": owner_repo,
179
+ "environment": environment,
180
+ "production": bool(production),
181
+ "finalMutationState": receipt.get("finalMutationState"),
182
+ "mutated": False,
183
+ }