@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.
- package/assets/components.json +3 -3
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +25 -15
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +3 -1
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +3 -3
- package/assets/generated/core/core/skills/setup/SKILL.md +61 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +137 -18
- package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +52 -12
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +82 -6
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +103 -42
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +41 -4
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +159 -79
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +323 -0
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +22 -1
- package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +51 -11
- package/assets/generated/deployment/src/project_forge_deployment/proof_receipt.py +183 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +277 -23
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
204
|
+
if not isinstance(plan, dict) or set(plan) != required:
|
|
146
205
|
raise DeploymentKitError("Malformed environment proof plan.")
|
|
147
206
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
167
|
-
|
|
212
|
+
operation_error: Exception | None = None
|
|
213
|
+
|
|
168
214
|
try:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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)
|