@mriekki/project-forge 4.0.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.
@@ -10,9 +10,11 @@ import subprocess
10
10
  from typing import Any
11
11
 
12
12
  from .app_runtime import normalize_app_spec, plan_app_deploy
13
- from .app_runtime_workstation import apply_app_deploy_from_workstation
13
+ from .app_runtime_workstation import apply_app_deploy_with_preloaded_image
14
14
  from .box_platform import platform_status
15
15
  from .errors import DeploymentKitError
16
+ from .image_transfer import remote_image_present, transfer_image, verify_local_image
17
+ from .proof_receipt import write_environment_proof_receipt
16
18
  from .registry_access import normalize_registry_image
17
19
  from .teardown import tear_down_project
18
20
 
@@ -123,10 +125,68 @@ def plan_environment_proof(
123
125
  "requiredAuthorization": (
124
126
  PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE if production else ENVIRONMENT_PROOF_WRITE_PHRASE
125
127
  ),
128
+ }
129
+
130
+
131
+ def _proof_result(plan: dict[str, Any]) -> dict[str, Any]:
132
+ return {
133
+ "targetName": plan["targetName"],
134
+ "ownerRepo": plan["ownerRepo"],
135
+ "environment": plan["environment"],
136
+ "production": plan["production"],
137
+ "probeAppSlug": plan["probeAppSlug"],
138
+ "probeImageTag": plan["probeImageTag"],
139
+ "preflight": {
140
+ "targetAccess": "verified-by-platform-doctor",
141
+ "hostPlatform": "ready",
142
+ },
143
+ "registryDigest": None,
144
+ "localImageId": None,
145
+ "transferState": "not-started",
146
+ "remoteImageId": None,
147
+ "runtimeState": "not-started",
148
+ "httpHealth": "not-run",
149
+ "cleanupState": "not-run",
150
+ "imageCacheState": "unknown",
151
+ "proof": "failed",
126
152
  "mutated": False,
153
+ "finalMutationState": "none",
154
+ "error": None,
155
+ "cleanupError": None,
156
+ "receipt": None,
157
+ "receiptError": None,
127
158
  }
128
159
 
129
160
 
161
+ def _cleanup_state(cleanup: dict[str, Any] | None, environment: str) -> str:
162
+ if not isinstance(cleanup, dict):
163
+ return "unknown"
164
+ entry = next(
165
+ (item for item in cleanup.get("environments", []) if item.get("environment") == environment),
166
+ None,
167
+ )
168
+ if not isinstance(entry, dict):
169
+ return "unknown"
170
+ state = entry.get("runtimeAndPersistentData", {}).get("state")
171
+ return state if isinstance(state, str) and state else "unknown"
172
+
173
+
174
+ def _final_mutation_state(result: dict[str, Any]) -> str:
175
+ cleanup = result["cleanupState"]
176
+ cache = result["imageCacheState"]
177
+ if cleanup not in {"removed", "absent", "not-run"}:
178
+ return "cleanup-incomplete"
179
+ if cache == "present":
180
+ return "runtime-clean-image-cached"
181
+ if result["mutated"] and cache == "absent":
182
+ return "clean"
183
+ if result["mutated"]:
184
+ return "runtime-clean-cache-unknown"
185
+ if result["transferState"] == "attempting" and cache == "unknown":
186
+ return "mutation-unknown-after-transfer-attempt"
187
+ return "none"
188
+
189
+
130
190
  def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
131
191
  required = {
132
192
  "targetName",
@@ -140,89 +200,109 @@ def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
140
200
  "healthPath",
141
201
  "cleanup",
142
202
  "requiredAuthorization",
143
- "mutated",
144
203
  }
145
- if not isinstance(plan, dict) or set(plan) != required or plan["mutated"] is not False:
204
+ if not isinstance(plan, dict) or set(plan) != required:
146
205
  raise DeploymentKitError("Malformed environment proof plan.")
147
206
 
148
- exact_image = _resolve_probe_image(plan["probeImageTag"])
149
- spec = normalize_app_spec(
150
- owner_repo=plan["ownerRepo"],
151
- environment=plan["environment"],
152
- app_slug=plan["probeAppSlug"],
153
- container_name=f"{plan['probeAppSlug']}--{plan['environment']}",
154
- image=exact_image,
155
- runtime_port=80,
156
- health_path=plan["healthPath"],
157
- run_as="0",
158
- persistence_mode="none",
159
- data_container_path=None,
160
- environment_variables={},
161
- )
162
- deploy_plan = plan_app_deploy(plan["targetName"], spec)
163
-
207
+ result = _proof_result(plan)
208
+ deploy_plan: dict[str, Any] | None = None
209
+ transfer: dict[str, Any] | None = None
164
210
  deployment: dict[str, Any] | None = None
165
211
  cleanup: dict[str, Any] | None = None
166
- deploy_error: Exception | None = None
167
- cleanup_error: Exception | None = None
212
+ operation_error: Exception | None = None
213
+
168
214
  try:
169
- deployment = apply_app_deploy_from_workstation(deploy_plan)
170
- except Exception as error: # cleanup must still be attempted after a partially applied proof
171
- deploy_error = error
215
+ exact_image = _resolve_probe_image(plan["probeImageTag"])
216
+ result["registryDigest"] = exact_image
217
+ local = verify_local_image(exact_image)
218
+ result["localImageId"] = local["localImageId"]
219
+
220
+ spec = normalize_app_spec(
221
+ owner_repo=plan["ownerRepo"],
222
+ environment=plan["environment"],
223
+ app_slug=plan["probeAppSlug"],
224
+ container_name=f"{plan['probeAppSlug']}--{plan['environment']}",
225
+ image=exact_image,
226
+ runtime_port=80,
227
+ health_path=plan["healthPath"],
228
+ run_as="0",
229
+ persistence_mode="none",
230
+ data_container_path=None,
231
+ environment_variables={},
232
+ )
233
+ deploy_plan = plan_app_deploy(plan["targetName"], spec)
234
+
235
+ result["transferState"] = "attempting"
236
+ transfer = transfer_image(plan["targetName"], exact_image)
237
+ result["transferState"] = transfer["status"]
238
+ result["remoteImageId"] = transfer["remoteImageId"]
239
+ result["mutated"] = bool(transfer["mutated"])
240
+
241
+ result["runtimeState"] = "applying"
242
+ deployment = apply_app_deploy_with_preloaded_image(deploy_plan, transfer)
243
+ result["runtimeState"] = "healthy"
244
+ result["httpHealth"] = "passed"
245
+ result["mutated"] = True
246
+ except Exception as error: # terminal proof evidence must survive operational failure
247
+ operation_error = error
248
+ result["error"] = str(error)
249
+ if result["runtimeState"] == "applying":
250
+ result["runtimeState"] = "failed-or-partial"
251
+ result["httpHealth"] = "failed-or-not-reached"
172
252
  finally:
173
- try:
174
- cleanup = tear_down_project(
175
- {
176
- "ownerRepo": plan["ownerRepo"],
177
- "appSlug": plan["probeAppSlug"],
178
- "scope": plan["environment"],
179
- "environmentTargets": {plan["environment"]: plan["targetName"]},
180
- "localPackage": None,
181
- "productionEnvironments": [plan["environment"]] if plan["production"] else [],
182
- }
183
- )
184
- except Exception as error:
185
- cleanup_error = error
186
-
187
- if deploy_error is not None:
188
- detail = str(deploy_error)
189
- if cleanup_error is not None:
190
- detail += f" Cleanup also failed: {cleanup_error}"
191
- raise DeploymentKitError(f"Environment proof deployment failed: {detail}") from deploy_error
192
- if cleanup_error is not None:
193
- raise DeploymentKitError(
194
- f"Environment proof deployment succeeded but cleanup failed: {cleanup_error}"
195
- ) from cleanup_error
196
- assert deployment is not None and cleanup is not None
197
-
198
- matched = next(
199
- (
200
- item
201
- for item in deployment.get("applications", [])
202
- if item.get("appSlug") == plan["probeAppSlug"]
203
- and item.get("environment") == plan["environment"]
204
- ),
205
- None,
206
- )
207
- if not matched or not matched.get("containerRunning") or not matched.get("httpHealthy"):
208
- raise DeploymentKitError("Environment proof returned without healthy disposable runtime evidence.")
209
- cleanup_entry = next(
210
- (item for item in cleanup.get("environments", []) if item.get("environment") == plan["environment"]),
211
- None,
212
- )
213
- if cleanup_entry is None or cleanup_entry.get("runtimeAndPersistentData", {}).get("state") not in {"removed", "absent"}:
214
- raise DeploymentKitError("Environment proof cleanup did not return terminal runtime cleanup evidence.")
253
+ if deploy_plan is not None:
254
+ try:
255
+ cleanup = tear_down_project(
256
+ {
257
+ "ownerRepo": plan["ownerRepo"],
258
+ "appSlug": plan["probeAppSlug"],
259
+ "scope": plan["environment"],
260
+ "environmentTargets": {plan["environment"]: plan["targetName"]},
261
+ "localPackage": None,
262
+ "productionEnvironments": [plan["environment"]] if plan["production"] else [],
263
+ }
264
+ )
265
+ result["cleanupState"] = _cleanup_state(cleanup, plan["environment"])
266
+ if result["cleanupState"] == "removed":
267
+ result["mutated"] = True
268
+ except Exception as error:
269
+ result["cleanupState"] = "failed"
270
+ result["cleanupError"] = str(error)
215
271
 
216
- return {
217
- "targetName": plan["targetName"],
218
- "ownerRepo": plan["ownerRepo"],
219
- "environment": plan["environment"],
220
- "production": plan["production"],
221
- "probeAppSlug": plan["probeAppSlug"],
222
- "probeImage": exact_image,
223
- "artifactDelivery": "workstation-transfer",
224
- "runtimeHealthy": True,
225
- "cleanupState": cleanup_entry["runtimeAndPersistentData"]["state"],
226
- "proof": "passed",
227
- "mutated": True,
228
- }
272
+ image_id = result.get("localImageId")
273
+ if isinstance(image_id, str):
274
+ try:
275
+ cached = remote_image_present(plan["targetName"], image_id)
276
+ result["imageCacheState"] = "present" if cached else "absent"
277
+ if cached:
278
+ result["remoteImageId"] = image_id
279
+ if result["transferState"] == "attempting":
280
+ result["mutated"] = True
281
+ except Exception:
282
+ result["imageCacheState"] = "unknown"
283
+
284
+ cleanup_terminal = result["cleanupState"] in {"removed", "absent"}
285
+ if operation_error is None and result["cleanupError"] is None and cleanup_terminal:
286
+ assert deployment is not None
287
+ matched = next(
288
+ (
289
+ item
290
+ for item in deployment.get("applications", [])
291
+ if item.get("appSlug") == plan["probeAppSlug"]
292
+ and item.get("environment") == plan["environment"]
293
+ ),
294
+ None,
295
+ )
296
+ if matched and matched.get("containerRunning") and matched.get("httpHealthy"):
297
+ result["proof"] = "passed"
298
+ else:
299
+ result["error"] = "Environment proof returned without healthy disposable runtime evidence."
300
+ elif operation_error is None and result["cleanupError"] is None and not cleanup_terminal:
301
+ result["error"] = "Environment proof cleanup did not return terminal runtime cleanup evidence."
302
+
303
+ result["finalMutationState"] = _final_mutation_state(result)
304
+ try:
305
+ result["receipt"] = write_environment_proof_receipt(result)
306
+ except Exception as error: # proof result stays truthful even if local evidence persistence fails
307
+ result["receiptError"] = str(error)
308
+ return result
@@ -0,0 +1,323 @@
1
+ """Read-only orchestration for one remote Project Forge setup target.
2
+
3
+ This module intentionally composes existing deterministic primitives without applying
4
+ mutations. It gives conversational front ends one state machine to render instead of
5
+ reconstructing profile, SSH, host-platform, and proof readiness themselves.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .box_platform import platform_doctor
13
+ from .doctor import target_doctor, workstation_doctor
14
+ from .environment_probe import plan_environment_proof
15
+ from .errors import DeploymentKitError
16
+ from .profiles import load_profile
17
+ from .proof_receipt import load_environment_proof_receipt
18
+ from .ssh import diagnose_access
19
+
20
+
21
+ READINESS_ORDER = (
22
+ "workspace",
23
+ "targetDefinition",
24
+ "targetAccess",
25
+ "hostPlatform",
26
+ "deploymentPath",
27
+ "application",
28
+ )
29
+
30
+
31
+ def _phase(label: str, state: str, *, detail: Any = None) -> dict[str, Any]:
32
+ value: dict[str, Any] = {"label": label, "state": state}
33
+ if detail is not None:
34
+ value["detail"] = detail
35
+ return value
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
+
45
+ def remote_setup_status(name: str) -> dict[str, Any]:
46
+ """Return one read-only, terminal status model for remote environment setup."""
47
+
48
+ workstation = workstation_doctor()
49
+ target = target_doctor(name)
50
+
51
+ phases: dict[str, dict[str, Any]] = {
52
+ "workspace": _phase(
53
+ "WORKSPACE READY",
54
+ "ready" if workstation["state"] in {"configured", "partially-configured"} else workstation["state"],
55
+ detail=workstation,
56
+ ),
57
+ "targetDefinition": _phase(
58
+ "TARGET DEFINITION READY",
59
+ "ready" if target["state"] != "needs-profile" else "needs-profile",
60
+ detail=target,
61
+ ),
62
+ "targetAccess": _phase("TARGET ACCESS READY", "blocked"),
63
+ "hostPlatform": _phase("HOST PLATFORM READY", "blocked"),
64
+ "deploymentPath": _phase("DEPLOYMENT PATH PROVEN", "not-proven"),
65
+ "application": _phase("APPLICATION DEPLOYED", "not-part-of-setup"),
66
+ }
67
+
68
+ host_platform: dict[str, Any] | None = None
69
+
70
+ if target["state"] == "ready-to-verify":
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
+ )
77
+ elif target["state"] == "needs-profile":
78
+ phases["targetAccess"] = _phase("TARGET ACCESS READY", "blocked-by-target-definition")
79
+ else:
80
+ phases["targetAccess"] = _phase(
81
+ "TARGET ACCESS READY",
82
+ f"blocked-by-{target['state']}",
83
+ detail={"nextAction": target.get("nextAction")},
84
+ )
85
+
86
+ if phases["targetAccess"]["state"] == "ready":
87
+ try:
88
+ host_platform = platform_doctor(name)
89
+ phases["hostPlatform"] = _phase(
90
+ "HOST PLATFORM READY",
91
+ "ready" if host_platform["state"] == "ready" else host_platform["state"],
92
+ detail=host_platform,
93
+ )
94
+ except DeploymentKitError as error:
95
+ phases["hostPlatform"] = _phase(
96
+ "HOST PLATFORM READY",
97
+ "inspection-failed",
98
+ detail={"reason": str(error)},
99
+ )
100
+ else:
101
+ phases["hostPlatform"] = _phase("HOST PLATFORM READY", "blocked-by-target-access")
102
+
103
+ return {
104
+ "targetName": name,
105
+ "state": "host-platform-ready" if phases["hostPlatform"]["state"] == "ready" else "needs-setup",
106
+ "currentPhase": _current_phase(phases),
107
+ "phases": phases,
108
+ "mutated": False,
109
+ }
110
+
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
+
173
+ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
174
+ phases = status["phases"]
175
+ target = phases["targetDefinition"].get("detail") or {}
176
+
177
+ if phases["targetDefinition"]["state"] == "needs-profile":
178
+ return {
179
+ "kind": "missing-facts",
180
+ "action": "configure-target-profile",
181
+ "command": f"project-forge-deploy target configure {status['targetName']} <target facts>",
182
+ "reason": "A target profile does not exist yet; host/user/SSH facts must come from the developer or portable profile.",
183
+ }
184
+
185
+ target_state = target.get("state")
186
+ if target_state == "needs-key":
187
+ return {
188
+ "kind": "write-gated",
189
+ "action": "create-or-select-key",
190
+ "command": f"project-forge-deploy target key-create {status['targetName']}",
191
+ "authorization": "[LOCAL KEY WRITE]",
192
+ }
193
+ if target_state == "needs-trust":
194
+ return {
195
+ "kind": "human-verification",
196
+ "action": "verify-and-enroll-host-trust",
197
+ "command": f"project-forge-deploy target trust-candidates {status['targetName']}",
198
+ "reason": "Host-key trust requires an independently observed target-console fingerprint before enrollment.",
199
+ }
200
+ if target_state == "needs-address":
201
+ return {
202
+ "kind": "missing-facts",
203
+ "action": "set-verified-address",
204
+ "command": f"project-forge-deploy target address-set {status['targetName']} --address <verified-address>",
205
+ "reason": "Stable host identity exists but no usable address resolved; use only a separately verified current address.",
206
+ }
207
+ if target_state == "blocked":
208
+ return {
209
+ "kind": "blocked",
210
+ "action": target.get("nextAction", "repair-target-profile"),
211
+ "reason": target.get("reason", "Target definition is blocked."),
212
+ }
213
+
214
+ if phases["targetAccess"]["state"] != "ready":
215
+ return _access_recovery(status)
216
+
217
+ platform = phases["hostPlatform"].get("detail") or {}
218
+ platform_state = phases["hostPlatform"]["state"]
219
+ if platform_state == "installable":
220
+ return {
221
+ "kind": "write-gated",
222
+ "action": "install-host-platform",
223
+ "command": f"project-forge-deploy platform install {status['targetName']}",
224
+ "authorization": "[HOST PLATFORM WRITE]",
225
+ }
226
+ if platform_state == "upgrade-required":
227
+ return {
228
+ "kind": "write-gated",
229
+ "action": "upgrade-host-platform",
230
+ "command": f"project-forge-deploy platform upgrade {status['targetName']}",
231
+ "authorization": "[HOST PLATFORM WRITE]",
232
+ }
233
+ if platform_state in {"blocked-port-80", "drifted", "blocked", "incompatible", "inspection-failed"}:
234
+ return {
235
+ "kind": "blocked",
236
+ "action": platform.get("nextAction", "inspect-host-platform"),
237
+ "reason": platform.get("reason", f"Host platform state is {platform_state}."),
238
+ "safety": "Inspect exact ownership/state first; do not broadly prune or take over unrelated host resources.",
239
+ }
240
+ if platform_state != "ready":
241
+ return {
242
+ "kind": "blocked",
243
+ "action": "inspect-host-platform",
244
+ "reason": f"Host platform state is {platform_state}.",
245
+ }
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
+
255
+ return {
256
+ "kind": "proof-context-required",
257
+ "action": "plan-deployment-path-proof",
258
+ "reason": "Target access and host platform are ready. Owner/repository and logical environment are needed to plan the disposable proof.",
259
+ }
260
+
261
+
262
+ def remote_setup_plan(
263
+ name: str,
264
+ *,
265
+ owner_repo: str | None = None,
266
+ environment: str | None = None,
267
+ production: bool = False,
268
+ ) -> dict[str, Any]:
269
+ """Return the exact next deterministic setup action without mutating state."""
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
+
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
+
291
+ next_action = _next_action(status)
292
+
293
+ if (
294
+ next_action["action"] == "plan-deployment-path-proof"
295
+ and owner_repo is not None
296
+ and environment is not None
297
+ ):
298
+ proof = plan_environment_proof(
299
+ name,
300
+ owner_repo=owner_repo,
301
+ environment=environment,
302
+ production=production,
303
+ )
304
+ command = (
305
+ f"project-forge-deploy environment proof plan {name} "
306
+ f"--owner-repo {proof['ownerRepo']} --environment {proof['environment']}"
307
+ )
308
+ if production:
309
+ command += " --production"
310
+ next_action = {
311
+ "kind": "read-only",
312
+ "action": "review-deployment-path-proof",
313
+ "command": command,
314
+ "proofPlan": proof,
315
+ "runAuthorization": proof["requiredAuthorization"],
316
+ "previousProof": status["phases"]["deploymentPath"].get("detail"),
317
+ }
318
+
319
+ return {
320
+ **status,
321
+ "next": next_action,
322
+ "mutated": False,
323
+ }
@@ -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)