@mriekki/project-forge 3.0.0 → 4.1.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 +4 -4
- package/assets/core/distribution.json +0 -4
- package/assets/deployment/distribution.json +1 -0
- package/assets/generated/ASSEMBLY.json +22 -37
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +8 -2
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +1 -2
- package/assets/generated/core/core/process/OPERATION_JOURNAL.md +1 -2
- package/assets/generated/core/core/skills/grill-me/SKILL.md +84 -46
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +3 -3
- package/assets/generated/core/core/skills/setup/SKILL.md +149 -199
- package/assets/generated/core/core/skills/start-work/SKILL.md +5 -6
- package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +52 -12
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +56 -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 +152 -79
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +241 -0
- package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +51 -11
- package/package.json +1 -1
- package/assets/generated/core/core/agents/coach.agent.md +0 -69
- package/assets/generated/core/core/skills/core-sync/SKILL.md +0 -184
- package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +0 -231
- package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +0 -88
|
@@ -10,9 +10,10 @@ 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
|
|
16
17
|
from .registry_access import normalize_registry_image
|
|
17
18
|
from .teardown import tear_down_project
|
|
18
19
|
|
|
@@ -123,10 +124,66 @@ def plan_environment_proof(
|
|
|
123
124
|
"requiredAuthorization": (
|
|
124
125
|
PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE if production else ENVIRONMENT_PROOF_WRITE_PHRASE
|
|
125
126
|
),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _proof_result(plan: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
return {
|
|
132
|
+
"targetName": plan["targetName"],
|
|
133
|
+
"ownerRepo": plan["ownerRepo"],
|
|
134
|
+
"environment": plan["environment"],
|
|
135
|
+
"production": plan["production"],
|
|
136
|
+
"probeAppSlug": plan["probeAppSlug"],
|
|
137
|
+
"probeImageTag": plan["probeImageTag"],
|
|
138
|
+
"preflight": {
|
|
139
|
+
"targetAccess": "verified-by-platform-doctor",
|
|
140
|
+
"hostPlatform": "ready",
|
|
141
|
+
},
|
|
142
|
+
"registryDigest": None,
|
|
143
|
+
"localImageId": None,
|
|
144
|
+
"transferState": "not-started",
|
|
145
|
+
"remoteImageId": None,
|
|
146
|
+
"runtimeState": "not-started",
|
|
147
|
+
"httpHealth": "not-run",
|
|
148
|
+
"cleanupState": "not-run",
|
|
149
|
+
"imageCacheState": "unknown",
|
|
150
|
+
"proof": "failed",
|
|
126
151
|
"mutated": False,
|
|
152
|
+
"finalMutationState": "none",
|
|
153
|
+
"error": None,
|
|
154
|
+
"cleanupError": None,
|
|
127
155
|
}
|
|
128
156
|
|
|
129
157
|
|
|
158
|
+
def _cleanup_state(cleanup: dict[str, Any] | None, environment: str) -> str:
|
|
159
|
+
if not isinstance(cleanup, dict):
|
|
160
|
+
return "unknown"
|
|
161
|
+
entry = next(
|
|
162
|
+
(item for item in cleanup.get("environments", []) if item.get("environment") == environment),
|
|
163
|
+
None,
|
|
164
|
+
)
|
|
165
|
+
if not isinstance(entry, dict):
|
|
166
|
+
return "unknown"
|
|
167
|
+
state = entry.get("runtimeAndPersistentData", {}).get("state")
|
|
168
|
+
return state if isinstance(state, str) and state else "unknown"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _final_mutation_state(result: dict[str, Any]) -> str:
|
|
172
|
+
cleanup = result["cleanupState"]
|
|
173
|
+
cache = result["imageCacheState"]
|
|
174
|
+
if cleanup not in {"removed", "absent", "not-run"}:
|
|
175
|
+
return "cleanup-incomplete"
|
|
176
|
+
if cache == "present":
|
|
177
|
+
return "runtime-clean-image-cached"
|
|
178
|
+
if result["mutated"] and cache == "absent":
|
|
179
|
+
return "clean"
|
|
180
|
+
if result["mutated"]:
|
|
181
|
+
return "runtime-clean-cache-unknown"
|
|
182
|
+
if result["transferState"] == "attempting" and cache == "unknown":
|
|
183
|
+
return "mutation-unknown-after-transfer-attempt"
|
|
184
|
+
return "none"
|
|
185
|
+
|
|
186
|
+
|
|
130
187
|
def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
|
|
131
188
|
required = {
|
|
132
189
|
"targetName",
|
|
@@ -140,89 +197,105 @@ def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
140
197
|
"healthPath",
|
|
141
198
|
"cleanup",
|
|
142
199
|
"requiredAuthorization",
|
|
143
|
-
"mutated",
|
|
144
200
|
}
|
|
145
|
-
if not isinstance(plan, dict) or set(plan) != required
|
|
201
|
+
if not isinstance(plan, dict) or set(plan) != required:
|
|
146
202
|
raise DeploymentKitError("Malformed environment proof plan.")
|
|
147
203
|
|
|
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
|
-
|
|
204
|
+
result = _proof_result(plan)
|
|
205
|
+
deploy_plan: dict[str, Any] | None = None
|
|
206
|
+
transfer: dict[str, Any] | None = None
|
|
164
207
|
deployment: dict[str, Any] | None = None
|
|
165
208
|
cleanup: dict[str, Any] | None = None
|
|
166
|
-
|
|
167
|
-
|
|
209
|
+
operation_error: Exception | None = None
|
|
210
|
+
|
|
168
211
|
try:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
212
|
+
exact_image = _resolve_probe_image(plan["probeImageTag"])
|
|
213
|
+
result["registryDigest"] = exact_image
|
|
214
|
+
local = verify_local_image(exact_image)
|
|
215
|
+
result["localImageId"] = local["localImageId"]
|
|
216
|
+
|
|
217
|
+
spec = normalize_app_spec(
|
|
218
|
+
owner_repo=plan["ownerRepo"],
|
|
219
|
+
environment=plan["environment"],
|
|
220
|
+
app_slug=plan["probeAppSlug"],
|
|
221
|
+
container_name=f"{plan['probeAppSlug']}--{plan['environment']}",
|
|
222
|
+
image=exact_image,
|
|
223
|
+
runtime_port=80,
|
|
224
|
+
health_path=plan["healthPath"],
|
|
225
|
+
run_as="0",
|
|
226
|
+
persistence_mode="none",
|
|
227
|
+
data_container_path=None,
|
|
228
|
+
environment_variables={},
|
|
229
|
+
)
|
|
230
|
+
deploy_plan = plan_app_deploy(plan["targetName"], spec)
|
|
231
|
+
|
|
232
|
+
result["transferState"] = "attempting"
|
|
233
|
+
transfer = transfer_image(plan["targetName"], exact_image)
|
|
234
|
+
result["transferState"] = transfer["status"]
|
|
235
|
+
result["remoteImageId"] = transfer["remoteImageId"]
|
|
236
|
+
result["mutated"] = bool(transfer["mutated"])
|
|
237
|
+
|
|
238
|
+
result["runtimeState"] = "applying"
|
|
239
|
+
deployment = apply_app_deploy_with_preloaded_image(deploy_plan, transfer)
|
|
240
|
+
result["runtimeState"] = "healthy"
|
|
241
|
+
result["httpHealth"] = "passed"
|
|
242
|
+
result["mutated"] = True
|
|
243
|
+
except Exception as error: # terminal proof evidence must survive operational failure
|
|
244
|
+
operation_error = error
|
|
245
|
+
result["error"] = str(error)
|
|
246
|
+
if result["runtimeState"] == "applying":
|
|
247
|
+
result["runtimeState"] = "failed-or-partial"
|
|
248
|
+
result["httpHealth"] = "failed-or-not-reached"
|
|
172
249
|
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.")
|
|
250
|
+
if deploy_plan is not None:
|
|
251
|
+
try:
|
|
252
|
+
cleanup = tear_down_project(
|
|
253
|
+
{
|
|
254
|
+
"ownerRepo": plan["ownerRepo"],
|
|
255
|
+
"appSlug": plan["probeAppSlug"],
|
|
256
|
+
"scope": plan["environment"],
|
|
257
|
+
"environmentTargets": {plan["environment"]: plan["targetName"]},
|
|
258
|
+
"localPackage": None,
|
|
259
|
+
"productionEnvironments": [plan["environment"]] if plan["production"] else [],
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
result["cleanupState"] = _cleanup_state(cleanup, plan["environment"])
|
|
263
|
+
if result["cleanupState"] == "removed":
|
|
264
|
+
result["mutated"] = True
|
|
265
|
+
except Exception as error:
|
|
266
|
+
result["cleanupState"] = "failed"
|
|
267
|
+
result["cleanupError"] = str(error)
|
|
215
268
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
269
|
+
image_id = result.get("localImageId")
|
|
270
|
+
if isinstance(image_id, str):
|
|
271
|
+
try:
|
|
272
|
+
cached = remote_image_present(plan["targetName"], image_id)
|
|
273
|
+
result["imageCacheState"] = "present" if cached else "absent"
|
|
274
|
+
if cached:
|
|
275
|
+
result["remoteImageId"] = image_id
|
|
276
|
+
if result["transferState"] == "attempting":
|
|
277
|
+
result["mutated"] = True
|
|
278
|
+
except Exception:
|
|
279
|
+
result["imageCacheState"] = "unknown"
|
|
280
|
+
|
|
281
|
+
cleanup_terminal = result["cleanupState"] in {"removed", "absent"}
|
|
282
|
+
if operation_error is None and result["cleanupError"] is None and cleanup_terminal:
|
|
283
|
+
assert deployment is not None
|
|
284
|
+
matched = next(
|
|
285
|
+
(
|
|
286
|
+
item
|
|
287
|
+
for item in deployment.get("applications", [])
|
|
288
|
+
if item.get("appSlug") == plan["probeAppSlug"]
|
|
289
|
+
and item.get("environment") == plan["environment"]
|
|
290
|
+
),
|
|
291
|
+
None,
|
|
292
|
+
)
|
|
293
|
+
if matched and matched.get("containerRunning") and matched.get("httpHealthy"):
|
|
294
|
+
result["proof"] = "passed"
|
|
295
|
+
else:
|
|
296
|
+
result["error"] = "Environment proof returned without healthy disposable runtime evidence."
|
|
297
|
+
elif operation_error is None and result["cleanupError"] is None and not cleanup_terminal:
|
|
298
|
+
result["error"] = "Environment proof cleanup did not return terminal runtime cleanup evidence."
|
|
299
|
+
|
|
300
|
+
result["finalMutationState"] = _final_mutation_state(result)
|
|
301
|
+
return result
|
|
@@ -0,0 +1,241 @@
|
|
|
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 .ssh import verify_access
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
READINESS_ORDER = (
|
|
21
|
+
"workspace",
|
|
22
|
+
"targetDefinition",
|
|
23
|
+
"targetAccess",
|
|
24
|
+
"hostPlatform",
|
|
25
|
+
"deploymentPath",
|
|
26
|
+
"application",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _phase(label: str, state: str, *, detail: Any = None) -> dict[str, Any]:
|
|
31
|
+
value: dict[str, Any] = {"label": label, "state": state}
|
|
32
|
+
if detail is not None:
|
|
33
|
+
value["detail"] = detail
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def remote_setup_status(name: str) -> dict[str, Any]:
|
|
38
|
+
"""Return one read-only, terminal status model for remote environment setup."""
|
|
39
|
+
|
|
40
|
+
workstation = workstation_doctor()
|
|
41
|
+
target = target_doctor(name)
|
|
42
|
+
|
|
43
|
+
phases: dict[str, dict[str, Any]] = {
|
|
44
|
+
"workspace": _phase(
|
|
45
|
+
"WORKSPACE READY",
|
|
46
|
+
"ready" if workstation["state"] in {"configured", "partially-configured"} else workstation["state"],
|
|
47
|
+
detail=workstation,
|
|
48
|
+
),
|
|
49
|
+
"targetDefinition": _phase(
|
|
50
|
+
"TARGET DEFINITION READY",
|
|
51
|
+
"ready" if target["state"] != "needs-profile" else "needs-profile",
|
|
52
|
+
detail=target,
|
|
53
|
+
),
|
|
54
|
+
"targetAccess": _phase("TARGET ACCESS READY", "blocked"),
|
|
55
|
+
"hostPlatform": _phase("HOST PLATFORM READY", "blocked"),
|
|
56
|
+
"deploymentPath": _phase("DEPLOYMENT PATH PROVEN", "not-proven"),
|
|
57
|
+
"application": _phase("APPLICATION DEPLOYED", "not-part-of-setup"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
target_access: dict[str, Any] | None = None
|
|
61
|
+
host_platform: dict[str, Any] | None = None
|
|
62
|
+
|
|
63
|
+
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
|
+
)
|
|
73
|
+
elif target["state"] == "needs-profile":
|
|
74
|
+
phases["targetAccess"] = _phase("TARGET ACCESS READY", "blocked-by-target-definition")
|
|
75
|
+
else:
|
|
76
|
+
phases["targetAccess"] = _phase(
|
|
77
|
+
"TARGET ACCESS READY",
|
|
78
|
+
f"blocked-by-{target['state']}",
|
|
79
|
+
detail={"nextAction": target.get("nextAction")},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if phases["targetAccess"]["state"] == "ready":
|
|
83
|
+
try:
|
|
84
|
+
host_platform = platform_doctor(name)
|
|
85
|
+
phases["hostPlatform"] = _phase(
|
|
86
|
+
"HOST PLATFORM READY",
|
|
87
|
+
"ready" if host_platform["state"] == "ready" else host_platform["state"],
|
|
88
|
+
detail=host_platform,
|
|
89
|
+
)
|
|
90
|
+
except DeploymentKitError as error:
|
|
91
|
+
phases["hostPlatform"] = _phase(
|
|
92
|
+
"HOST PLATFORM READY",
|
|
93
|
+
"inspection-failed",
|
|
94
|
+
detail={"reason": str(error)},
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
phases["hostPlatform"] = _phase("HOST PLATFORM READY", "blocked-by-target-access")
|
|
98
|
+
|
|
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
|
+
return {
|
|
104
|
+
"targetName": name,
|
|
105
|
+
"state": "host-platform-ready" if phases["hostPlatform"]["state"] == "ready" else "needs-setup",
|
|
106
|
+
"currentPhase": current,
|
|
107
|
+
"phases": phases,
|
|
108
|
+
"mutated": False,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _next_action(status: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
phases = status["phases"]
|
|
114
|
+
target = phases["targetDefinition"].get("detail") or {}
|
|
115
|
+
|
|
116
|
+
if phases["targetDefinition"]["state"] == "needs-profile":
|
|
117
|
+
return {
|
|
118
|
+
"kind": "missing-facts",
|
|
119
|
+
"action": "configure-target-profile",
|
|
120
|
+
"command": f"project-forge-deploy target configure {status['targetName']} <target facts>",
|
|
121
|
+
"reason": "A target profile does not exist yet; host/user/SSH facts must come from the developer or portable profile.",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
target_state = target.get("state")
|
|
125
|
+
if target_state == "needs-key":
|
|
126
|
+
return {
|
|
127
|
+
"kind": "write-gated",
|
|
128
|
+
"action": "create-or-select-key",
|
|
129
|
+
"command": f"project-forge-deploy target key-create {status['targetName']}",
|
|
130
|
+
"authorization": "[LOCAL KEY WRITE]",
|
|
131
|
+
}
|
|
132
|
+
if target_state == "needs-trust":
|
|
133
|
+
return {
|
|
134
|
+
"kind": "human-verification",
|
|
135
|
+
"action": "verify-and-enroll-host-trust",
|
|
136
|
+
"command": f"project-forge-deploy target trust-candidates {status['targetName']}",
|
|
137
|
+
"reason": "Host-key trust requires an independently observed target-console fingerprint before enrollment.",
|
|
138
|
+
}
|
|
139
|
+
if target_state == "needs-address":
|
|
140
|
+
return {
|
|
141
|
+
"kind": "missing-facts",
|
|
142
|
+
"action": "set-verified-address",
|
|
143
|
+
"command": f"project-forge-deploy target address-set {status['targetName']} --address <verified-address>",
|
|
144
|
+
"reason": "Stable host identity exists but no usable address resolved; use only a separately verified current address.",
|
|
145
|
+
}
|
|
146
|
+
if target_state == "blocked":
|
|
147
|
+
return {
|
|
148
|
+
"kind": "blocked",
|
|
149
|
+
"action": target.get("nextAction", "repair-target-profile"),
|
|
150
|
+
"reason": target.get("reason", "Target definition is blocked."),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
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
|
+
}
|
|
160
|
+
|
|
161
|
+
platform = phases["hostPlatform"].get("detail") or {}
|
|
162
|
+
platform_state = phases["hostPlatform"]["state"]
|
|
163
|
+
if platform_state == "installable":
|
|
164
|
+
return {
|
|
165
|
+
"kind": "write-gated",
|
|
166
|
+
"action": "install-host-platform",
|
|
167
|
+
"command": f"project-forge-deploy platform install {status['targetName']}",
|
|
168
|
+
"authorization": "[HOST PLATFORM WRITE]",
|
|
169
|
+
}
|
|
170
|
+
if platform_state == "upgrade-required":
|
|
171
|
+
return {
|
|
172
|
+
"kind": "write-gated",
|
|
173
|
+
"action": "upgrade-host-platform",
|
|
174
|
+
"command": f"project-forge-deploy platform upgrade {status['targetName']}",
|
|
175
|
+
"authorization": "[HOST PLATFORM WRITE]",
|
|
176
|
+
}
|
|
177
|
+
if platform_state in {"blocked-port-80", "drifted", "blocked", "incompatible", "inspection-failed"}:
|
|
178
|
+
return {
|
|
179
|
+
"kind": "blocked",
|
|
180
|
+
"action": platform.get("nextAction", "inspect-host-platform"),
|
|
181
|
+
"reason": platform.get("reason", f"Host platform state is {platform_state}."),
|
|
182
|
+
"safety": "Inspect exact ownership/state first; do not broadly prune or take over unrelated host resources.",
|
|
183
|
+
}
|
|
184
|
+
if platform_state != "ready":
|
|
185
|
+
return {
|
|
186
|
+
"kind": "blocked",
|
|
187
|
+
"action": "inspect-host-platform",
|
|
188
|
+
"reason": f"Host platform state is {platform_state}.",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"kind": "proof-context-required",
|
|
193
|
+
"action": "plan-deployment-path-proof",
|
|
194
|
+
"reason": "Target access and host platform are ready. Owner/repository and logical environment are needed to plan the disposable proof.",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def remote_setup_plan(
|
|
199
|
+
name: str,
|
|
200
|
+
*,
|
|
201
|
+
owner_repo: str | None = None,
|
|
202
|
+
environment: str | None = None,
|
|
203
|
+
production: bool = False,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
"""Return the exact next deterministic setup action without mutating state."""
|
|
206
|
+
|
|
207
|
+
status = remote_setup_status(name)
|
|
208
|
+
next_action = _next_action(status)
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
next_action["action"] == "plan-deployment-path-proof"
|
|
212
|
+
and owner_repo is not None
|
|
213
|
+
and environment is not None
|
|
214
|
+
):
|
|
215
|
+
proof = plan_environment_proof(
|
|
216
|
+
name,
|
|
217
|
+
owner_repo=owner_repo,
|
|
218
|
+
environment=environment,
|
|
219
|
+
production=production,
|
|
220
|
+
)
|
|
221
|
+
command = (
|
|
222
|
+
f"project-forge-deploy environment proof plan {name} "
|
|
223
|
+
f"--owner-repo {proof['ownerRepo']} --environment {proof['environment']}"
|
|
224
|
+
)
|
|
225
|
+
if production:
|
|
226
|
+
command += " --production"
|
|
227
|
+
next_action = {
|
|
228
|
+
"kind": "read-only",
|
|
229
|
+
"action": "review-deployment-path-proof",
|
|
230
|
+
"command": command,
|
|
231
|
+
"proofPlan": proof,
|
|
232
|
+
"runAuthorization": proof["requiredAuthorization"],
|
|
233
|
+
}
|
|
234
|
+
elif (owner_repo is None) != (environment is None):
|
|
235
|
+
raise DeploymentKitError("ownerRepo and environment must be supplied together for setup proof planning.")
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
**status,
|
|
239
|
+
"next": next_action,
|
|
240
|
+
"mutated": False,
|
|
241
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import shlex
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
@@ -13,6 +14,9 @@ from .registry_access import normalize_registry_image
|
|
|
13
14
|
from .ssh import ssh_args, verify_access
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
_IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
18
|
+
|
|
19
|
+
|
|
16
20
|
def _docker() -> str:
|
|
17
21
|
path = shutil.which("docker")
|
|
18
22
|
if path is None:
|
|
@@ -20,13 +24,25 @@ def _docker() -> str:
|
|
|
20
24
|
return path
|
|
21
25
|
|
|
22
26
|
|
|
27
|
+
def _image_id(value: str) -> str:
|
|
28
|
+
if not isinstance(value, str) or _IMAGE_ID_RE.fullmatch(value) is None:
|
|
29
|
+
raise DeploymentKitError("Docker image ID must be sha256:<64 lowercase hex>.")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
23
33
|
def verify_local_image(image: str) -> dict[str, str]:
|
|
34
|
+
"""Verify the declared immutable registry digest and resolve its local config/image ID.
|
|
35
|
+
|
|
36
|
+
Registry RepoDigests are release identity. Docker's image ID is the config digest and is
|
|
37
|
+
the content identity preserved by ``docker save``/``docker load`` even when RepoTags are
|
|
38
|
+
absent from the archive.
|
|
39
|
+
"""
|
|
40
|
+
|
|
24
41
|
exact = normalize_registry_image(image)
|
|
25
42
|
result = subprocess.run(
|
|
26
|
-
[_docker(), "image", "inspect", exact],
|
|
43
|
+
[_docker(), "image", "inspect", "--format", "{{.Id}}", exact],
|
|
27
44
|
check=False,
|
|
28
|
-
|
|
29
|
-
stderr=subprocess.PIPE,
|
|
45
|
+
capture_output=True,
|
|
30
46
|
text=True,
|
|
31
47
|
encoding="utf-8",
|
|
32
48
|
errors="replace",
|
|
@@ -36,11 +52,17 @@ def verify_local_image(image: str) -> dict[str, str]:
|
|
|
36
52
|
f"Exact image is not present in the workstation Docker engine: {exact}. "
|
|
37
53
|
"Pull/build it locally before transfer."
|
|
38
54
|
)
|
|
39
|
-
|
|
55
|
+
image_id = _image_id(result.stdout.strip())
|
|
56
|
+
return {
|
|
57
|
+
"image": exact,
|
|
58
|
+
"registryDigest": exact,
|
|
59
|
+
"localImageId": image_id,
|
|
60
|
+
}
|
|
40
61
|
|
|
41
62
|
|
|
42
|
-
def _remote_has_image(profile: dict[str, Any], address: str,
|
|
43
|
-
|
|
63
|
+
def _remote_has_image(profile: dict[str, Any], address: str, image_id: str) -> bool:
|
|
64
|
+
exact_id = _image_id(image_id)
|
|
65
|
+
command = "sudo -n docker image inspect " + shlex.quote(exact_id) + " >/dev/null 2>&1"
|
|
44
66
|
result = subprocess.run(
|
|
45
67
|
ssh_args(profile, address, command=command),
|
|
46
68
|
check=False,
|
|
@@ -50,17 +72,31 @@ def _remote_has_image(profile: dict[str, Any], address: str, image: str) -> bool
|
|
|
50
72
|
return result.returncode == 0
|
|
51
73
|
|
|
52
74
|
|
|
75
|
+
def remote_image_present(name: str, image_id: str) -> bool:
|
|
76
|
+
"""Read-only check for one exact Docker config/image ID on a verified target."""
|
|
77
|
+
|
|
78
|
+
exact_id = _image_id(image_id)
|
|
79
|
+
profile = load_profile(name)
|
|
80
|
+
access = verify_access(profile)
|
|
81
|
+
return _remote_has_image(profile, access["address"], exact_id)
|
|
82
|
+
|
|
83
|
+
|
|
53
84
|
def transfer_image(name: str, image: str) -> dict[str, Any]:
|
|
54
|
-
|
|
55
|
-
|
|
85
|
+
local = verify_local_image(image)
|
|
86
|
+
exact = local["registryDigest"]
|
|
87
|
+
image_id = local["localImageId"]
|
|
56
88
|
profile = load_profile(name)
|
|
57
89
|
access = verify_access(profile)
|
|
58
90
|
address = access["address"]
|
|
59
91
|
|
|
60
|
-
if _remote_has_image(profile, address,
|
|
92
|
+
if _remote_has_image(profile, address, image_id):
|
|
61
93
|
return {
|
|
62
94
|
"targetName": name,
|
|
63
95
|
"image": exact,
|
|
96
|
+
"registryDigest": exact,
|
|
97
|
+
"localImageId": image_id,
|
|
98
|
+
"remoteImageId": image_id,
|
|
99
|
+
"runtimeImage": image_id,
|
|
64
100
|
"status": "already-present",
|
|
65
101
|
"mutated": False,
|
|
66
102
|
}
|
|
@@ -93,13 +129,17 @@ def transfer_image(name: str, image: str) -> dict[str, Any]:
|
|
|
93
129
|
)
|
|
94
130
|
raise DeploymentKitError(f"Remote Docker image load failed: {detail}")
|
|
95
131
|
|
|
96
|
-
if not _remote_has_image(profile, address,
|
|
132
|
+
if not _remote_has_image(profile, address, image_id):
|
|
97
133
|
raise DeploymentKitError(
|
|
98
|
-
"Remote docker load completed but the exact
|
|
134
|
+
"Remote docker load completed but the exact Docker config/image ID is not inspectable on the target."
|
|
99
135
|
)
|
|
100
136
|
return {
|
|
101
137
|
"targetName": name,
|
|
102
138
|
"image": exact,
|
|
139
|
+
"registryDigest": exact,
|
|
140
|
+
"localImageId": image_id,
|
|
141
|
+
"remoteImageId": image_id,
|
|
142
|
+
"runtimeImage": image_id,
|
|
103
143
|
"status": "transferred",
|
|
104
144
|
"mutated": True,
|
|
105
145
|
}
|