@mriekki/project-forge 4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.0.0",
4
+ "version": "4.1.0",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -14,7 +14,7 @@
14
14
  "components": {
15
15
  "core": {
16
16
  "repository": "mriekki/project-forge-core",
17
- "commit": "e20e9b0e7102477a30bc15a771d62ba537efe60c",
17
+ "commit": "738dca3e03d110da0837d35d45101d2315f13a6f",
18
18
  "version": "4.0.0",
19
19
  "distribution": "greenfield-workspace",
20
20
  "descriptor": "assets/core/distribution.json",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "deployment": {
25
25
  "repository": "mriekki/project-forge-deployment-kit",
26
- "commit": "0974f36bcb5062c4becb7c5c4e0fbc3e629ad4a9",
26
+ "commit": "149812159cafdfeeca04645e129392021b8b9e09",
27
27
  "distribution": "greenfield-project",
28
28
  "descriptor": "assets/deployment/distribution.json",
29
29
  "sourceRoot": "assets/generated/deployment",
@@ -18,6 +18,7 @@
18
18
  {"source": "src/project_forge_deployment/entrypoint.py", "destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py"},
19
19
  {"source": "src/project_forge_deployment/environment_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/environment_cli.py"},
20
20
  {"source": "src/project_forge_deployment/environment_probe.py", "destination": "tools/project-forge-deployment/project_forge_deployment/environment_probe.py"},
21
+ {"source": "src/project_forge_deployment/environment_setup.py", "destination": "tools/project-forge-deployment/project_forge_deployment/environment_setup.py"},
21
22
  {"source": "src/project_forge_deployment/errors.py", "destination": "tools/project-forge-deployment/project_forge_deployment/errors.py"},
22
23
  {"source": "src/project_forge_deployment/home_lab.py", "destination": "tools/project-forge-deployment/project_forge_deployment/home_lab.py"},
23
24
  {"source": "src/project_forge_deployment/image_transfer.py", "destination": "tools/project-forge-deployment/project_forge_deployment/image_transfer.py"},
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.0.0",
4
+ "version": "4.1.0",
5
5
  "components": {
6
6
  "core": {
7
7
  "repository": "mriekki/project-forge-core",
8
- "commit": "e20e9b0e7102477a30bc15a771d62ba537efe60c",
8
+ "commit": "738dca3e03d110da0837d35d45101d2315f13a6f",
9
9
  "version": "4.0.0",
10
10
  "distribution": "greenfield-workspace",
11
11
  "files": [
@@ -102,7 +102,7 @@
102
102
  {
103
103
  "source": "core/skills/project-forge-update/SKILL.md",
104
104
  "destination": ".github/skills/project-forge-update/SKILL.md",
105
- "sha256": "3463a68171fa30ef10f1ca514424236317ec364f015c5446f49911a1a3c9bb12"
105
+ "sha256": "510d60fab8d91e54abbc49dbc963a93b132ac5cdfd97ec87f64128ca138d2b1a"
106
106
  },
107
107
  {
108
108
  "source": "core/skills/upstream-fix/SKILL.md",
@@ -328,7 +328,7 @@
328
328
  },
329
329
  "deployment": {
330
330
  "repository": "mriekki/project-forge-deployment-kit",
331
- "commit": "0974f36bcb5062c4becb7c5c4e0fbc3e629ad4a9",
331
+ "commit": "149812159cafdfeeca04645e129392021b8b9e09",
332
332
  "distribution": "greenfield-project",
333
333
  "files": [
334
334
  {
@@ -364,7 +364,7 @@
364
364
  {
365
365
  "source": "src/project_forge_deployment/app_runtime_workstation.py",
366
366
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime_workstation.py",
367
- "sha256": "a176074fc7948b802f22adb03730d53b12aac520ba476604a2a9c1e8e29a52b4"
367
+ "sha256": "17723294fffce40433343df5fd26817df037a8029d473acefa6b767a56c9f725"
368
368
  },
369
369
  {
370
370
  "source": "src/project_forge_deployment/box_platform.py",
@@ -399,12 +399,17 @@
399
399
  {
400
400
  "source": "src/project_forge_deployment/environment_cli.py",
401
401
  "destination": "tools/project-forge-deployment/project_forge_deployment/environment_cli.py",
402
- "sha256": "ccb38228bd8bf975f9ca9c9298b172b23bec4af7192da0c1cf3aa39bde4dce2d"
402
+ "sha256": "3c8a0c67c8b75fed7374d1b7b013f95392f84661b6ea182187d07a2b48f206ae"
403
403
  },
404
404
  {
405
405
  "source": "src/project_forge_deployment/environment_probe.py",
406
406
  "destination": "tools/project-forge-deployment/project_forge_deployment/environment_probe.py",
407
- "sha256": "3971264b425c9eb49845a48a54aea849ef35b36b86b13017c0946d3b90e9d707"
407
+ "sha256": "5f4bba4a9e01b97eccdf67919d49894c9d264fae0edd63f742baaa18e42f69d0"
408
+ },
409
+ {
410
+ "source": "src/project_forge_deployment/environment_setup.py",
411
+ "destination": "tools/project-forge-deployment/project_forge_deployment/environment_setup.py",
412
+ "sha256": "1554c2954a024f8419f44eb2ab3fdc129334de41df6038628bade9cb7a803950"
408
413
  },
409
414
  {
410
415
  "source": "src/project_forge_deployment/errors.py",
@@ -419,7 +424,7 @@
419
424
  {
420
425
  "source": "src/project_forge_deployment/image_transfer.py",
421
426
  "destination": "tools/project-forge-deployment/project_forge_deployment/image_transfer.py",
422
- "sha256": "de8666d8687e1544603fa259c6b302f2f29ab699e6d01b8795b9fce28821c7ed"
427
+ "sha256": "dadd1fe7724372a4ca0e04ab2280769b6c2381f0f9ae75a096f6c5a5f5add755"
423
428
  },
424
429
  {
425
430
  "source": "src/project_forge_deployment/keys.py",
@@ -539,7 +544,7 @@
539
544
  {
540
545
  "source": "docs/DEVELOPER_ENVIRONMENTS.md",
541
546
  "destination": "docs/project-forge/DEVELOPER_ENVIRONMENTS.md",
542
- "sha256": "e818931055044f6158ffe321303a5cbcd2f58724430d51e87c1688e1f71a8fbc"
547
+ "sha256": "cac10b7e30db24992225a88b43e16aa55404317c87a2ef136f21a2589ee4dc5f"
543
548
  },
544
549
  {
545
550
  "source": "docs/WORKSPACE_INSTANCES.md",
@@ -549,7 +554,7 @@
549
554
  {
550
555
  "source": "docs/ENVIRONMENT_PROOF.md",
551
556
  "destination": "docs/project-forge/ENVIRONMENT_PROOF.md",
552
- "sha256": "d139ec1691d073e845a679edf45cb01c971a40b3ccc31404efb8c1e81e481ea5"
557
+ "sha256": "f635a87ada6a22deb09a3ac0f1c99f8e32119bb8bff3613d77db2ed876dc2552"
553
558
  },
554
559
  {
555
560
  "source": "docs/images/developer-environment-journey.svg",
@@ -49,7 +49,7 @@ Do not synthesize or repair missing umbrella provenance by hand.
49
49
 
50
50
  If `PROJECT_BOOTSTRAP.json` exists, treat it as approved generated-consumer provenance, not unrelated product data.
51
51
 
52
- Before planning an update, require `generation.projectForge.package`, `generation.projectForge.version`, and `generation.projectForge.profile` to agree exactly with the installed `PROJECT_FORGE_BASELINE.json`. Require `generation.projectForge.integrity` to be a non-empty exact registry integrity value for that installed package/version. Verify that integrity read-only from the registry when registry access is available.
52
+ Before planning an update, require `generation["project-forge"].package`, `generation["project-forge"].version`, and `generation["project-forge"].profile` to agree exactly with the installed `PROJECT_FORGE_BASELINE.json`. Require `generation["project-forge"].integrity` to be a non-empty exact registry integrity value for that installed package/version. Verify that integrity read-only from the registry when registry access is available.
53
53
 
54
54
  If those values disagree before the update, report `GENERATED PROJECT FORGE METADATA DRIFT` and stop. Do not use `/project-forge-update` to silently reconcile an already-incoherent generated consumer.
55
55
 
@@ -117,7 +117,7 @@ A modified retired Core path is different: never auto-add `--accept-retired core
117
117
 
118
118
  For a Bootstrap-generated consumer, also preview the companion generated-consumer changes:
119
119
 
120
- - `PROJECT_BOOTSTRAP.json` keeps the same package/profile but changes `generation.projectForge.version` and `generation.projectForge.integrity` to the exact reviewed target;
120
+ - `PROJECT_BOOTSTRAP.json` keeps the same package/profile but changes `generation["project-forge"].version` and `generation["project-forge"].integrity` to the exact reviewed target;
121
121
  - generated package-verification surfaces should consume `PROJECT_BOOTSTRAP.json` dynamically when the accepted Bootstrap contract supports that form;
122
122
  - a recognized older Bootstrap-generated verification block may be migrated only when its embedded old package/version/integrity exactly match the currently approved manifest metadata;
123
123
  - an unrecognized or locally modified generated verification surface is a stop condition, not permission to reconstruct Bootstrap output by hand.
@@ -152,7 +152,7 @@ Choosing `Apply the exact Project Forge package update` authorizes only:
152
152
  - creation/switch to one repository-appropriate short-lived update branch when needed;
153
153
  - exact target package apply;
154
154
  - removal of unchanged retired package-managed files that the reviewed preview classified as `REMOVE`;
155
- - when `PROJECT_BOOTSTRAP.json` is present, update of its exact `generation.projectForge.version` and `generation.projectForge.integrity` to the reviewed target while preserving package/profile;
155
+ - when `PROJECT_BOOTSTRAP.json` is present, update of its exact `generation["project-forge"].version` and `generation["project-forge"].integrity` to the reviewed target while preserving package/profile;
156
156
  - migration of a recognized Bootstrap-generated package-verification block from duplicated current literals to the accepted manifest-driven form, only when the existing block still exactly matches the current approved metadata;
157
157
  - package/baseline/generated-consumer verification;
158
158
  - inspection of the resulting meaningful diff.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Status: **CORE WORKSTATION/TARGET MECHANICS IMPLEMENTED; AUTOMATED HOST PACKAGE PREPARATION REMAINS INCREMENTAL**
4
4
 
5
- This document is the canonical developer-environment model for Project Forges. It describes the developer-facing progression from a generated repository to local development, optional Staging/Production capability, additional workstations, and additional remote boxes.
5
+ This document is the canonical developer-environment model for Project Forge. It describes the developer-facing progression from a generated repository to local development, optional Staging/Production capability, additional workstations, and additional remote boxes.
6
6
 
7
7
  The happy path intentionally hides Bootstrap/package/control-plane plumbing. Developers should see meaningful milestones and actionable setup, while the underlying repositories retain exact provenance and safety evidence.
8
8
 
@@ -44,6 +44,37 @@ The normal conversational entry point is:
44
44
 
45
45
  The first local success target is `LOCAL WORKSPACE READY`. Remote access must not block it unless the Project explicitly requires a remote dependency for local development.
46
46
 
47
+ For remote onboarding, conversational `/setup remote` or `/setup <environment>` should consume one deterministic read-only setup state machine rather than manually reconstructing low-level steps:
48
+
49
+ ```text
50
+ project-forge-deploy environment setup status <target> --json
51
+ project-forge-deploy environment setup plan <target> --json
52
+ ```
53
+
54
+ When target access and the host platform are ready, proof context can be included without performing a write:
55
+
56
+ ```text
57
+ project-forge-deploy environment setup plan <target> \
58
+ --owner-repo <owner/repo> \
59
+ --environment <logical-environment> \
60
+ --json
61
+ ```
62
+
63
+ Add `--production` only when that logical environment is Production-class. Setup status/plan never authorizes a write; they report the existing authorization phrase and exact next deterministic operation when a write is required.
64
+
65
+ Readiness is deliberately separated into:
66
+
67
+ ```text
68
+ WORKSPACE READY
69
+ TARGET DEFINITION READY
70
+ TARGET ACCESS READY
71
+ HOST PLATFORM READY
72
+ DEPLOYMENT PATH PROVEN
73
+ APPLICATION DEPLOYED
74
+ ```
75
+
76
+ The last state is not part of setup. A successful disposable proof does not deploy the Project application.
77
+
47
78
  ## Progress and next action
48
79
 
49
80
  Use `/progress` to answer what is complete, what is blocked/deferred, who owns the next action, and local/Staging/Production capability state.
@@ -101,6 +132,8 @@ An empty profile with `targets: []` is valid. A local-only developer never needs
101
132
 
102
133
  `profile sync` fetches and validates the GitHub profile, updates the validated local cache, and with `--apply` creates only missing non-conflicting local target definitions. Existing differing local definitions are never overwritten.
103
134
 
135
+ Portable identity deliberately excludes workstation-local `addressOverride`, identity-file, and trust-file state. A separately verified temporary IP therefore does not make the same portable host definition a different target.
136
+
104
137
  `profile publish` merges new non-conflicting local target definitions into the private profile and fails on conflicts rather than silently replacing remote definitions.
105
138
 
106
139
  ### Workstation-local state
@@ -116,6 +149,8 @@ Each workstation owns its own:
116
149
 
117
150
  Private SSH keys are never synchronized between computers. A Mark Windows key, Mark Mac key, Kyle Mac key, and Daniel Windows key are distinct identities even when they can reach the same remote server.
118
151
 
152
+ The deterministic doctor resolves and reports the actual SSH executable selected from the workstation environment; all Deployment Kit SSH operations use that resolved toolchain rather than asking the developer to mix shell-specific SSH clients manually.
153
+
119
154
  ## Home Lab remote onboarding
120
155
 
121
156
  The implemented Home Lab path is password-assisted bootstrap followed by normal strict key-only operation.
@@ -164,7 +199,7 @@ The first Project on a computer may install/verify required local tools and Dock
164
199
 
165
200
  ### Remote Ubuntu target
166
201
 
167
- Target access can now be established from the local workstation, but full automatic Ubuntu package preparation is still an incremental capability. The desired host progression remains:
202
+ Target access can be established from the local workstation, but full automatic Ubuntu package preparation is still an incremental capability. The desired host progression remains:
168
203
 
169
204
  ```text
170
205
  Ubuntu/sudo verification
@@ -175,7 +210,7 @@ Ubuntu/sudo verification
175
210
  → TARGET READY
176
211
  ```
177
212
 
178
- The current platform doctor/install contract still requires host prerequisites to exist before shared-platform installation. Until a reviewed host-package bootstrap is added, `/setup remote` must report missing host packages as an actionable preparation gap rather than pretending they were installed.
213
+ The current platform doctor/install contract still requires host prerequisites to exist before shared-platform installation. Until a reviewed host-package bootstrap is added, `/setup remote` reports missing host packages as an actionable preparation gap rather than pretending they were installed. The deterministic setup plan keeps this as a distinct `HOST PLATFORM READY` blocker.
179
214
 
180
215
  Healthy existing Docker/Project Forge installations are adopted/verified, not recreated. Ambiguous/incompatible existing state fails closed rather than being overwritten.
181
216
 
@@ -188,13 +223,16 @@ The implemented workstation-mediated artifact path is:
188
223
  ```text
189
224
  local GitHub/GHCR authentication when needed
190
225
  → acquire/build exact immutable image locally
191
- → verify exact identity/digest
226
+ → preserve registry RepoDigest as declared/release identity
227
+ → resolve local Docker config/image ID
192
228
  → stream Docker image over verified SSH
193
229
  → remote docker load
194
- → verify exact image remotely
195
- → deploy/health-check using the preloaded image
230
+ → verify the same config/image ID remotely
231
+ → deploy/health-check by that verified loaded image ID
196
232
  ```
197
233
 
234
+ Docker `save`/`load` does not guarantee that a registry RepoDigest remains inspectable as a remote RepoDigest after load. Project Forge therefore uses two explicit identities: registry digest for reviewed release provenance, and Docker config/image ID for transport verification and the preloaded runtime reference.
235
+
198
236
  Remote Home Lab boxes therefore do not need a developer's persistent GitHub PAT for the normal workstation-mediated deployment path.
199
237
 
200
238
  Application deployment selects this with:
@@ -203,7 +241,7 @@ Application deployment selects this with:
203
241
  --image-source workstation
204
242
  ```
205
243
 
206
- The legacy `--image-source remote-registry` path remains available during migration/for explicit target-side registry use.
244
+ The legacy `--image-source remote-registry` path remains available for explicit target-side registry use.
207
245
 
208
246
  A credential needed to acquire a private image is entered only through the local Docker/GitHub credential boundary. Never paste it into chat or put it on a command line.
209
247
 
@@ -275,7 +313,7 @@ When GitHub cannot be reached, profile sync can use the validated last-known-goo
275
313
 
276
314
  ## Migration for existing developers
277
315
 
278
- Existing Mark/Kyle environments should migrate fully to this model rather than remain permanently dual-mode.
316
+ Existing developer environments should migrate fully to this model rather than remain permanently dual-mode.
279
317
 
280
318
  The migration contract is:
281
319
 
@@ -284,8 +322,8 @@ inventory existing workstation/targets
284
322
  → ensure private personal project-forge-profile + validated cache
285
323
  → adopt healthy existing Docker/SSH/trust where valid
286
324
  → establish workstation-mediated artifact delivery
287
- → prove Staging end-to-end
288
- → verify Production access/readiness without deploying
325
+ → prove a non-Production environment end-to-end
326
+ → verify Production access/readiness without deploying when Production is configured
289
327
  → remove/archive superseded legacy configuration only after verification
290
328
  ```
291
329
 
@@ -325,11 +363,13 @@ The intended small human surface is:
325
363
 
326
364
  ```text
327
365
  /setup
366
+ /setup local
367
+ /setup remote
368
+ /setup <environment>
328
369
  /progress
329
370
  /next-step
330
- /developer-env-mode status|local|remote
331
371
  /deployment-readiness <environment>
332
372
  /deploy <environment>
333
373
  ```
334
374
 
335
- The first three are the normal guided experience. `project-forge-deploy environment ...` commands are deterministic implementation/fallback surfaces and can be invoked directly when desired.
375
+ `/setup` is the single setup front door; the local/remote/environment forms select its scope rather than introducing a separate developer-environment command. `project-forge-deploy environment ...` commands are deterministic implementation/fallback surfaces and can be invoked directly when desired.
@@ -13,15 +13,24 @@ this workstation
13
13
  → verified target/profile access
14
14
  → ready shared host platform
15
15
  → pull small stable probe image locally
16
- → resolve exact immutable RepoDigest
17
- → transfer exact image over verified SSH
18
- → deploy with the managed application runtime
16
+ → resolve exact immutable registry RepoDigest
17
+ → resolve local Docker config/image ID
18
+ → transfer image over verified SSH
19
+ → verify the same config/image ID after remote docker load
20
+ → deploy with the managed application runtime using that verified loaded image ID
19
21
  → verify HTTP health
20
22
  → tear down the reserved probe runtime
21
- → verify cleanup evidence
23
+ → verify cleanup and image-cache evidence
22
24
  ```
23
25
 
24
- The current default probe image tag is `nginx:alpine`. The tag is never treated as the deployed identity: Project Forge pulls it locally and resolves an exact `@sha256:` RepoDigest before image transfer or remote runtime mutation.
26
+ The current default probe image tag is `nginx:alpine`. The mutable tag is never treated as deployed identity.
27
+
28
+ Project Forge deliberately records two exact identities because Docker `save`/`load` does not guarantee that a registry RepoDigest remains inspectable under the same repository reference after load:
29
+
30
+ - **registry digest** — immutable reviewed artifact/release identity (`repository@sha256:...`);
31
+ - **Docker config/image ID** — content identity used to verify the workstation-to-target transfer and to run the preloaded image (`sha256:...`).
32
+
33
+ The registry digest remains in the deployment spec and release/proof evidence. The loaded runtime is verified and launched by the matching Docker config/image ID. A target that already has that exact image ID can reuse it without retransferring the image.
25
34
 
26
35
  The probe uses:
27
36
 
@@ -33,6 +42,20 @@ The probe uses:
33
42
  - the existing per-app/per-environment deployment lock;
34
43
  - automatic cleanup through the existing scoped tear-down machinery.
35
44
 
45
+ ## Setup orchestration
46
+
47
+ Before running proof commands directly, the read-only remote setup orchestrator can show whether target access and the host platform are actually ready:
48
+
49
+ ```text
50
+ project-forge-deploy environment setup status <target> --json
51
+ project-forge-deploy environment setup plan <target> \
52
+ --owner-repo <owner/repo> \
53
+ --environment <name> \
54
+ --json
55
+ ```
56
+
57
+ These commands never perform the proof or authorize a write. They make `TARGET ACCESS READY`, `HOST PLATFORM READY`, and `DEPLOYMENT PATH PROVEN` separate states.
58
+
36
59
  ## Commands
37
60
 
38
61
  Read-only plan:
@@ -78,17 +101,44 @@ Production-class proof:
78
101
 
79
102
  A successful proof never authorizes the later real application deployment. Production proof never authorizes a Production application deployment.
80
103
 
104
+ ## Terminal evidence
105
+
106
+ A proof run returns a terminal result even when the operational path fails after the run begins. The result distinguishes planning from observed execution and includes, when available:
107
+
108
+ ```text
109
+ preflight
110
+ registryDigest
111
+ localImageId
112
+ transferState
113
+ remoteImageId
114
+ runtimeState
115
+ httpHealth
116
+ cleanupState
117
+ imageCacheState
118
+ proof
119
+ mutated
120
+ finalMutationState
121
+ error
122
+ cleanupError
123
+ ```
124
+
125
+ A read-only plan does not claim `mutated: false` as though it were the eventual run result. The run reports whether transfer/runtime mutation actually occurred and whether cleanup completed.
126
+
127
+ A failed proof exits nonzero even when structured terminal evidence was produced successfully.
128
+
81
129
  ## Cleanup
82
130
 
83
131
  The proof attempts cleanup even when deployment raises after mutation begins.
84
132
 
85
133
  Successful completion requires terminal cleanup evidence (`removed` or already `absent`) for the reserved probe runtime. If deployment succeeds but cleanup cannot be verified, the command fails rather than reporting a clean proof.
86
134
 
135
+ A loaded Docker image may remain cached after runtime cleanup. That state is reported explicitly (for example `runtime-clean-image-cached`) rather than being presented as an unmodified host. Cached image content is not application runtime or persistent application data.
136
+
87
137
  The probe must not delete another Project, another application, another logical environment, or shared host platform state.
88
138
 
89
139
  ## Relationship to `/setup` and `/deploy`
90
140
 
91
- `/setup` may run this proof after the developer selects a named remote environment to prepare on the current workstation.
141
+ `/setup remote` or `/setup <environment>` may use deterministic setup status/plan first, then run this proof after the developer selects the named remote environment and the relevant write authorization is granted.
92
142
 
93
143
  A later first real `/deploy <environment>` can reuse fresh proof evidence for target access/platform/transport mechanics and ask only unresolved application-specific questions such as persistence, health, route/exposure, and immutable application artifact policy.
94
144
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import subprocess
6
7
  from typing import Any
7
8
 
@@ -19,48 +20,42 @@ from .profiles import load_profile
19
20
  from .ssh import ssh_args, verify_access
20
21
 
21
22
 
22
- def _preloaded_script(spec: dict[str, Any]) -> str:
23
+ _IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
24
+
25
+
26
+ def _runtime_image_id(value: str) -> str:
27
+ if not isinstance(value, str) or _IMAGE_ID_RE.fullmatch(value) is None:
28
+ raise DeploymentKitError("Preloaded runtime image ID must be sha256:<64 lowercase hex>.")
29
+ return value
30
+
31
+
32
+ def _preloaded_script(spec: dict[str, Any], runtime_image_id: str) -> str:
33
+ runtime_image = _runtime_image_id(runtime_image_id)
23
34
  script = build_remote_script(spec)
24
35
  registry_block = " decode_desired\n configure_registry_auth\n"
25
36
  if registry_block not in script:
26
37
  raise DeploymentKitError("Application runtime template no longer contains the expected registry-auth hook.")
27
38
  script = script.replace(registry_block, " decode_desired\n", 1)
39
+
28
40
  pull = ' docker pull "$D_IMAGE" >/dev/null\n'
29
- replacement = ''' if ! docker image inspect "$D_IMAGE" >/dev/null 2>&1; then
30
- echo "Exact application image was not preloaded by the workstation-mediated deployment path." >&2
31
- exit 40
32
- fi
33
- '''
41
+ replacement = (
42
+ f' if ! docker image inspect "{runtime_image}" >/dev/null 2>&1; then\n'
43
+ ' echo "Exact application image ID was not preloaded by the workstation-mediated deployment path." >&2\n'
44
+ " exit 40\n"
45
+ " fi\n"
46
+ )
34
47
  if pull not in script:
35
48
  raise DeploymentKitError("Application runtime template no longer contains the expected image-pull hook.")
36
- return script.replace(pull, replacement, 1)
37
-
49
+ script = script.replace(pull, replacement, 1)
38
50
 
39
- def _run_preloaded_deploy(
40
- profile: dict[str, Any],
41
- address: str,
42
- spec: dict[str, Any],
43
- expected_spec_sha: str,
44
- ) -> None:
45
- command = ssh_args(
46
- profile,
47
- address,
48
- command="sudo -n bash -s -- deploy " + (expected_spec_sha or "NONE") + " " + profile["user"],
49
- )
50
- completed = subprocess.run(
51
- command,
52
- input=_preloaded_script(spec).encode("utf-8"),
53
- check=False,
54
- capture_output=True,
55
- )
56
- if completed.returncode != 0:
57
- stdout = completed.stdout.decode("utf-8", errors="replace")
58
- stderr = completed.stderr.decode("utf-8", errors="replace")
59
- detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
60
- raise DeploymentKitError(f"Workstation-mediated remote application deployment failed: {detail}")
51
+ runtime_hook = ' docker_args+=("$D_IMAGE")\n'
52
+ if runtime_hook not in script:
53
+ raise DeploymentKitError("Application runtime template no longer contains the expected runtime-image hook.")
54
+ script = script.replace(runtime_hook, f' docker_args+=("{runtime_image}")\n', 1)
55
+ return script
61
56
 
62
57
 
63
- def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
58
+ def _validate_plan(plan: dict[str, Any]) -> None:
64
59
  required = {
65
60
  "targetName",
66
61
  "action",
@@ -87,19 +82,67 @@ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
87
82
  if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
88
83
  raise DeploymentKitError("Malformed application deployment plan.")
89
84
 
90
- current = app_inventory(plan["targetName"])
91
- if _inventory_snapshot(current) != plan["inventorySnapshot"]:
92
- raise DeploymentKitError(
93
- "Application inventory changed after the plan was reviewed; create and authorize a fresh plan."
94
- )
95
- platform = platform_status(plan["targetName"])
96
- if platform["state"] != "ready":
97
- raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
98
85
 
99
- transfer_image(plan["targetName"], plan["image"])
86
+ def _validate_transfer(plan: dict[str, Any], transfer: dict[str, Any]) -> str:
87
+ required = {
88
+ "targetName",
89
+ "image",
90
+ "registryDigest",
91
+ "localImageId",
92
+ "remoteImageId",
93
+ "runtimeImage",
94
+ "status",
95
+ "mutated",
96
+ }
97
+ if not isinstance(transfer, dict) or set(transfer) != required:
98
+ raise DeploymentKitError("Malformed workstation image-transfer evidence.")
99
+ if transfer["targetName"] != plan["targetName"]:
100
+ raise DeploymentKitError("Image-transfer target does not match the deployment plan.")
101
+ if transfer["registryDigest"] != plan["image"] or transfer["image"] != plan["image"]:
102
+ raise DeploymentKitError("Image-transfer registry digest does not match the deployment plan.")
103
+ local_id = _runtime_image_id(transfer["localImageId"])
104
+ remote_id = _runtime_image_id(transfer["remoteImageId"])
105
+ runtime_id = _runtime_image_id(transfer["runtimeImage"])
106
+ if not (local_id == remote_id == runtime_id):
107
+ raise DeploymentKitError("Workstation and remote Docker image IDs do not match.")
108
+ if transfer["status"] not in {"already-present", "transferred"} or not isinstance(transfer["mutated"], bool):
109
+ raise DeploymentKitError("Image-transfer evidence has an invalid terminal state.")
110
+ return runtime_id
111
+
112
+
113
+ def _run_preloaded_deploy(
114
+ profile: dict[str, Any],
115
+ address: str,
116
+ spec: dict[str, Any],
117
+ expected_spec_sha: str,
118
+ runtime_image_id: str,
119
+ ) -> None:
120
+ command = ssh_args(
121
+ profile,
122
+ address,
123
+ command="sudo -n bash -s -- deploy " + (expected_spec_sha or "NONE") + " " + profile["user"],
124
+ )
125
+ completed = subprocess.run(
126
+ command,
127
+ input=_preloaded_script(spec, runtime_image_id).encode("utf-8"),
128
+ check=False,
129
+ capture_output=True,
130
+ )
131
+ if completed.returncode != 0:
132
+ stdout = completed.stdout.decode("utf-8", errors="replace")
133
+ stderr = completed.stderr.decode("utf-8", errors="replace")
134
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
135
+ raise DeploymentKitError(f"Workstation-mediated remote application deployment failed: {detail}")
136
+
137
+
138
+ def apply_app_deploy_with_preloaded_image(
139
+ plan: dict[str, Any], transfer: dict[str, Any]
140
+ ) -> dict[str, Any]:
141
+ """Apply a reviewed deployment plan using already-verified transfer evidence."""
142
+
143
+ _validate_plan(plan)
144
+ runtime_image_id = _validate_transfer(plan, transfer)
100
145
 
101
- # Image transfer can take time. Re-check the material app/platform evidence before
102
- # changing runtime state; a changed plan must never ride through on stale approval.
103
146
  current = app_inventory(plan["targetName"])
104
147
  if _inventory_snapshot(current) != plan["inventorySnapshot"]:
105
148
  raise DeploymentKitError(
@@ -116,6 +159,7 @@ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
116
159
  evidence["address"],
117
160
  plan["spec"],
118
161
  plan["expectedSpecSha256"] or "",
162
+ runtime_image_id,
119
163
  )
120
164
 
121
165
  final = app_inventory(plan["targetName"])
@@ -132,5 +176,22 @@ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
132
176
  raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
133
177
  final["mutated"] = True
134
178
  final["imageSource"] = "workstation-transfer"
179
+ final["imageTransfer"] = transfer
135
180
  final["attachesSharedNetwork"] = NETWORK_NAME
136
181
  return final
182
+
183
+
184
+ def apply_app_deploy_from_workstation(plan: dict[str, Any]) -> dict[str, Any]:
185
+ _validate_plan(plan)
186
+
187
+ current = app_inventory(plan["targetName"])
188
+ if _inventory_snapshot(current) != plan["inventorySnapshot"]:
189
+ raise DeploymentKitError(
190
+ "Application inventory changed after the plan was reviewed; create and authorize a fresh plan."
191
+ )
192
+ platform = platform_status(plan["targetName"])
193
+ if platform["state"] != "ready":
194
+ raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
195
+
196
+ transfer = transfer_image(plan["targetName"], plan["image"])
197
+ return apply_app_deploy_with_preloaded_image(plan, transfer)
@@ -1,4 +1,4 @@
1
- """CLI for portable developer environment definitions and Home Lab onboarding."""
1
+ """CLI for portable developer environment definitions and remote setup orchestration."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -15,6 +15,7 @@ from .environment_probe import (
15
15
  plan_environment_proof,
16
16
  run_environment_proof,
17
17
  )
18
+ from .environment_setup import remote_setup_plan, remote_setup_status
18
19
  from .errors import DeploymentKitError
19
20
  from .home_lab import HOME_LAB_BOOTSTRAP_PHRASE, apply_home_lab_bootstrap, plan_home_lab_bootstrap
20
21
  from .image_transfer import transfer_image, verify_local_image
@@ -59,6 +60,25 @@ def build_parser() -> argparse.ArgumentParser:
59
60
  parser = argparse.ArgumentParser(prog="project-forge-deploy environment")
60
61
  groups = parser.add_subparsers(dest="group", required=True)
61
62
 
63
+ setup = groups.add_parser(
64
+ "setup",
65
+ help="Read-only remote setup status/plan orchestration across target access, host platform, and proof readiness",
66
+ )
67
+ setup_sub = setup.add_subparsers(dest="command", required=True)
68
+ setup_status = setup_sub.add_parser("status", help="Show one terminal readiness model for a remote target")
69
+ setup_status.add_argument("name")
70
+ setup_status.add_argument("--json", action="store_true")
71
+ setup_plan = setup_sub.add_parser("plan", help="Show the exact next deterministic remote setup action")
72
+ setup_plan.add_argument("name")
73
+ setup_plan.add_argument("--owner-repo")
74
+ setup_plan.add_argument("--environment")
75
+ setup_plan.add_argument(
76
+ "--production",
77
+ action="store_true",
78
+ help="Classify the optional proof environment as Production",
79
+ )
80
+ setup_plan.add_argument("--json", action="store_true")
81
+
62
82
  profile = groups.add_parser("profile", help="Personal/portable non-secret target profile operations")
63
83
  profile_sub = profile.add_subparsers(dest="command", required=True)
64
84
 
@@ -178,6 +198,20 @@ def _home_lab_plan(args: argparse.Namespace) -> dict[str, Any]:
178
198
  def main(argv: list[str] | None = None) -> int:
179
199
  args = build_parser().parse_args(argv)
180
200
  try:
201
+ if args.group == "setup" and args.command == "status":
202
+ _print(remote_setup_status(args.name), as_json=args.json)
203
+ return 0
204
+ if args.group == "setup" and args.command == "plan":
205
+ _print(
206
+ remote_setup_plan(
207
+ args.name,
208
+ owner_repo=args.owner_repo,
209
+ environment=args.environment,
210
+ production=args.production,
211
+ ),
212
+ as_json=args.json,
213
+ )
214
+ return 0
181
215
  if args.group == "profile" and args.command == "status":
182
216
  _print(repository_status(), as_json=args.json)
183
217
  return 0
@@ -243,16 +277,19 @@ def main(argv: list[str] | None = None) -> int:
243
277
  production=args.production,
244
278
  probe_image=args.probe_image,
245
279
  )
246
- _print(plan, as_json=args.json if args.command == "plan" else False)
247
280
  if args.command == "plan":
281
+ _print(plan, as_json=args.json)
248
282
  return 0
283
+ if not args.json:
284
+ _print(plan, as_json=False)
249
285
  _confirm(
250
286
  args,
251
287
  plan["requiredAuthorization"],
252
288
  f"run a disposable deployment proof for {plan['environment']} on target {plan['targetName']}",
253
289
  )
254
- _print(run_environment_proof(plan), as_json=args.json)
255
- return 0
290
+ result = run_environment_proof(plan)
291
+ _print(result, as_json=args.json)
292
+ return 0 if result.get("proof") == "passed" else 2
256
293
  raise DeploymentKitError("Unsupported environment command.")
257
294
  except DeploymentKitError as error:
258
295
  print(f"ERROR: {error}", file=sys.stderr)
@@ -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 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
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 or plan["mutated"] is not False:
201
+ if not isinstance(plan, dict) or set(plan) != required:
146
202
  raise DeploymentKitError("Malformed environment proof plan.")
147
203
 
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
-
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
- deploy_error: Exception | None = None
167
- cleanup_error: Exception | None = None
209
+ operation_error: Exception | None = None
210
+
168
211
  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
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
- 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.")
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
- 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
- }
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
- stdout=subprocess.DEVNULL,
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
- return {"image": exact, "localImage": "verified"}
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, image: str) -> bool:
43
- command = "sudo -n docker image inspect " + shlex.quote(image) + " >/dev/null 2>&1"
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
- exact = normalize_registry_image(image)
55
- verify_local_image(exact)
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, exact):
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, exact):
132
+ if not _remote_has_image(profile, address, image_id):
97
133
  raise DeploymentKitError(
98
- "Remote docker load completed but the exact immutable image reference is not inspectable on the target."
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {