@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.
- package/assets/components.json +3 -3
- package/assets/deployment/distribution.json +1 -0
- package/assets/generated/ASSEMBLY.json +15 -10
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +3 -3
- 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/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
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": "
|
|
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": "
|
|
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.
|
|
4
|
+
"version": "4.1.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"repository": "mriekki/project-forge-core",
|
|
8
|
-
"commit": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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`
|
|
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
|
-
→
|
|
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
|
|
195
|
-
→ deploy/health-check
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
→
|
|
18
|
-
→
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
49
|
+
script = script.replace(pull, replacement, 1)
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
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
|
}
|