@mriekki/project-forge 4.6.0 → 4.8.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.
@@ -78,7 +78,78 @@ It does not prove final application artifact identity, persistence/migration beh
78
78
 
79
79
  If topology, target binding, host trust, platform state, or another material fact changes, treat the proof as stale and reverify the affected prerequisite.
80
80
 
81
- ## 3. Placement branches
81
+ ## 3. First-application outcome assessment
82
+
83
+ For the first real application deployment to an environment, or whenever the developer's stated goal is a usable endpoint rather than only machine readiness, keep that requested outcome visible and assess the whole material path before declaring the application deploy-ready.
84
+
85
+ Inspect available non-secret repository, package/tool, workstation, target, and GitHub/CI evidence before asking for facts. Do not require a release workflow to fail merely to discover a prerequisite that can be identified read-only.
86
+
87
+ Build a compact prerequisite matrix with rows such as:
88
+
89
+ ```text
90
+ requested outcome / expected endpoint
91
+ workstation architecture
92
+ required artifact/target architecture
93
+ immutable artifact or release identity policy
94
+ artifact destination / publication state when relevant
95
+ credential capability + execution location for each required operation
96
+ workstation artifact acquisition capability
97
+ target delivery method
98
+ current application isolation / footprint
99
+ runtime health contract
100
+ route / exposure mechanism
101
+ final workstation/browser verification
102
+ ```
103
+
104
+ For each material row distinguish:
105
+
106
+ ```text
107
+ evidence source
108
+ execution location / credential context
109
+ PROVEN / UNKNOWN / BLOCKED / NOT APPLICABLE
110
+ missing fact or prerequisite
111
+ exact consequential gate, if any
112
+ effect on the requested endpoint
113
+ ```
114
+
115
+ Architecture is a portability fact, not an operating-system stereotype. For example, an ARM64 workstation targeting a Linux/x86-64 environment must surface the required artifact platform explicitly; do not label that generically as a macOS defect.
116
+
117
+ Do not report `ENVIRONMENT READY TO DEPLOY` for the first real application while its actual artifact build/publication/acquisition path is still hypothetical. Target/platform readiness and application-deployment readiness are different dimensions.
118
+
119
+ Keep these evidence dimensions separate in status language:
120
+
121
+ ```text
122
+ static/review correctness
123
+ live external-contract confidence
124
+ credential capability in the exact execution context
125
+ immutable artifact provenance
126
+ environment/runtime readiness
127
+ route state
128
+ workstation/browser validation
129
+ human access confirmation
130
+ formal work-item acceptance
131
+ ```
132
+
133
+ A green result in one dimension never proves another.
134
+
135
+ ### Credential capability and lifecycle
136
+
137
+ Reason from **operation + execution context**, not from the existence of a generic token or successful access somewhere else. When installed tooling exposes structured registry capability evidence, preserve fields such as execution location, credential context, capability proved, and `doesNotProve` rather than paraphrasing a target result as workstation access.
138
+
139
+ Before asking the developer to create or rotate a credential, explain non-secret lifecycle facts first:
140
+
141
+ ```text
142
+ purpose / capability
143
+ machine, workflow, or process where it is used
144
+ where the credential will be stored
145
+ whether its value can be retrieved again
146
+ rotation/regeneration impact on existing consumers
147
+ value must remain in the local/provider secret surface, never chat
148
+ ```
149
+
150
+ Do not assume registry transport access proves package-metadata API access, that target registry access proves workstation access, or that a workstation login proves an Actions workflow capability. Provider-specific token types, scopes, and error-body strings belong to installed/provider-owned adapters or Project evidence, not generic Core invention.
151
+
152
+ ## 4. Placement branches
82
153
 
83
154
  ### A. Workstation placement
84
155
 
@@ -188,7 +259,7 @@ DEPLOYMENT INTENTIONALLY DEFERRED
188
259
 
189
260
  Do not mark workspace readiness failed. Route to `/setup <environment>` or `/setup remote` if the developer later wants to bind it.
190
261
 
191
- ## 4. Shared failure domains / colocated environments
262
+ ## 5. Shared failure domains / colocated environments
192
263
 
193
264
  Multiple logical environments may share physical failure domains, but they remain independently targetable and independently authorized. If environments share physical failure, state it explicitly.
194
265
 
@@ -196,7 +267,7 @@ A clear application isolation preflight means the current Deployment Kit-managed
196
267
 
197
268
  Colocation does not demonstrate host-level Production isolation. Never infer readiness for one logical environment from another merely because they share hardware.
198
269
 
199
- ## 5. State-driven cadence
270
+ ## 6. State-driven cadence
200
271
 
201
272
  Normal cadence is:
202
273
 
@@ -205,6 +276,7 @@ select exact project-defined environment
205
276
  → read current topology
206
277
  → reuse fresh environment-proof evidence when valid
207
278
  → rediscover placement-relevant local state
279
+ → for first real deployment, assess the complete artifact/credential/delivery/route/endpoint path
208
280
  → verify current application isolation when the installed adapter supports it
209
281
  → verify/repair only missing prerequisites under exact gates
210
282
  → ENVIRONMENT READY TO DEPLOY
@@ -213,7 +285,9 @@ select exact project-defined environment
213
285
 
214
286
  For remote placement, reuse valid existing target access rather than creating duplicate credentials simply because another Project/environment uses the same machine.
215
287
 
216
- ## 6. Read-only status
288
+ When a bounded engineering/delivery correction completes in the same conversation and the developer's deployment outcome is still clear, automatically resume safe read-only readiness reconstruction from current repository/tool evidence. Do not require the developer to type `/deployment-readiness` again merely as navigation. This continuation does not reuse an exhausted write authorization and does not authorize a new registry, Production, route, migration, merge, or other consequential write.
289
+
290
+ ## 7. Read-only status
217
291
 
218
292
  `/deployment-readiness status [environment]` performs no remote writes. Without a specific environment, summarize all known environments from project topology, including Placement and deferred state where evidenced.
219
293
 
@@ -229,7 +303,7 @@ Archive demo deferred NOT APPLICABLE DEFERRED
229
303
 
230
304
  Do not manufacture target/provider facts for deferred environments.
231
305
 
232
- ## 7. Bounded readiness writes
306
+ ## 8. Bounded readiness writes
233
307
 
234
308
  A missing prerequisite is not authorization to change it. Use the repository/tool's exact authorization phrase for key/trust/platform/provider mutations.
235
309
 
@@ -237,21 +311,23 @@ Do not hide the real application deployment inside readiness. Readiness may rely
237
311
 
238
312
  The application isolation preflight itself is read-only and does not authorize later deployment, route, persistence, migration, Production, or destructive writes.
239
313
 
240
- ## 8. Pre-deploy versus post-deploy evidence
314
+ ## 9. Pre-deploy versus post-deploy evidence
241
315
 
242
316
  Readiness asks whether `/deploy <name>` can safely perform the real application deployment now.
243
317
 
244
- Pre-deploy evidence may include topology, adapter availability, binding, local/remote identity and access, target platform, fresh proof evidence, artifact policy, deterministic application isolation/collision evidence, route/port availability, persistence/backup/migration prerequisites, lock/recovery capability, and Production-specific safeguards.
318
+ Pre-deploy evidence may include topology, adapter availability, binding, local/remote identity and access, target platform, fresh proof evidence, artifact policy, actual artifact acquisition/publication capability when material, deterministic application isolation/collision evidence, route/port availability, persistence/backup/migration prerequisites, lock/recovery capability, and Production-specific safeguards.
245
319
 
246
320
  Do not require first-deployment live route, deployed-release identity, application health, or smoke evidence before the application has ever been deployed. Those are post-deploy validation evidence owned by `/deploy`.
247
321
 
248
- ## 9. Production policy
322
+ The intended endpoint may still be known before deployment. When the route does not yet exist, label it explicitly `NOT ACTIVE — ROUTE PENDING`; do not invite the developer to test it as though readiness had made it reachable.
323
+
324
+ ## 10. Production policy
249
325
 
250
326
  Production is logical policy, not hardware count and not the literal name `production`. A named environment such as `prod-east` with purpose `production` must retain Production readiness and later deployment gates.
251
327
 
252
328
  Readiness never authorizes Production application deployment.
253
329
 
254
- ## 10. Final handoff
330
+ ## 11. Final handoff
255
331
 
256
332
  When pre-deploy requirements are satisfied:
257
333
 
@@ -263,10 +339,14 @@ Purpose: <purpose>
263
339
  Placement: <...>
264
340
  Binding: VERIFIED / LOCAL / provider-specific
265
341
  This-workstation environment proof: PROVEN / NOT REQUIRED / NOT AVAILABLE
342
+ Artifact path: PROVEN / NOT APPLICABLE
343
+ Credential contexts: PROVEN / NOT APPLICABLE
266
344
  Application isolation: CLEAR / NOT APPLICABLE
267
345
  Isolation evidence: <evidenceSha256 when exposed by installed tooling>
268
346
  Platform prerequisites: READY
269
347
  Application-specific prerequisites: READY / <remaining items>
348
+ Expected endpoint: <URL/route when known>
349
+ Endpoint state: NOT ACTIVE — ROUTE PENDING / LOOPBACK PENDING / provider-specific
270
350
  Security blockers: NONE
271
351
 
272
352
  POST-DEPLOY VALIDATION PENDING
@@ -65,6 +65,14 @@ Do not require another workstation to be online and do not treat another compute
65
65
 
66
66
  Detect the current OS automatically. Do not ask the developer which setup launcher to use when the platform can be observed.
67
67
 
68
+ If the observed workstation OS is not declared in mutable `DEV_ENVIRONMENT.json`, surface that explicitly before claiming setup support:
69
+
70
+ ```text
71
+ NEW WORKSTATION — PLATFORM NOT DECLARED
72
+ ```
73
+
74
+ Do not silently reinterpret Bootstrap provenance or append a platform merely because tools happen to run. If current repository/catalog evidence supports the stack on the observed OS, offer one reviewed **consumer-owned** platform expansion to `DEV_ENVIRONMENT.json`; otherwise report the concrete unsupported/capability gap. Preserve original `PROJECT_BOOTSTRAP.json` provenance.
75
+
68
76
  A workspace instance owns machine-local state such as local runtime/data, SSH private keys/trust, credential-manager entries, Docker state, and repository-scoped readiness evidence. Git/GitHub is the shared source-code coordination boundary. Never invent a central workstation registry merely to coordinate another computer.
69
77
 
70
78
  ## 2. `status` is read-only
@@ -112,6 +120,10 @@ setup --check
112
120
 
113
121
  A manually invoked `/setup` authorizes routine, bounded, non-destructive local setup that the generated script already owns: supported tool installation, VS Code extension installation, dependency restore, personal Project Forge profile initialization, and other declared workstation preparation.
114
122
 
123
+ Before a large workstation install run, surface all currently knowable prerequisites together when the generated tooling exposes them: missing tools, runtime/version pins, editor extensions, Docker operational state, interactive elevation/terminal requirements, and durable fresh-shell discovery. Group unavoidable sudo/administrator or secret-bearing prompts into clear terminal handoffs instead of discovering them one installer at a time.
124
+
125
+ A temporary process `PATH` adjustment may prove a diagnosis but does not by itself prove durable tool discovery. Re-check important runtime commands in the environment a fresh terminal/editor will use before reporting final readiness.
126
+
115
127
  Stop before destructive replacement, unrelated machine mutation, secret disclosure, or repair outside the declared setup contract.
116
128
 
117
129
  ## 4. `/setup local` converges verified local development readiness
@@ -132,7 +144,7 @@ When application source/runtime exists, converge only the repository-owned local
132
144
 
133
145
  Verify supported tools/versions using repository setup/doctor tooling when present. Run repository-defined dependency restore/setup commands when missing or stale. Preserve pinned versions and project conventions.
134
146
 
135
- Treat **installation**, **version compatibility**, and **operational readiness** as separate evidence when tooling exposes those distinctions. A successful CLI/version probe does not prove that the backing service/runtime is operational. For Docker-based setup, do not call the runtime ready merely because `docker --version` succeeds; require the repository/doctor operational probe before relying on Docker Engine capability.
147
+ Treat **installation**, **version compatibility**, and **operational readiness** as separate evidence when tooling exposes those distinctions. A successful CLI/version probe does not prove that the backing service/runtime is operational for the required Project Forge action. For Docker-based setup, do not call the runtime ready merely because `docker --version` succeeds; require the repository/doctor operational probe before relying on Docker Engine capability.
136
148
 
137
149
  ### B. Local persistence
138
150
 
@@ -266,6 +278,26 @@ For remote SSH preserve strict host identity. Unknown/changed host identity is a
266
278
 
267
279
  Remote readiness does not require configuring Production when the selected environment is non-Production. Production classification remains project topology/policy, not a literal-name check.
268
280
 
281
+ ### One read-only remote preflight before recovery
282
+
283
+ Before asking the developer to repair one SSH prerequisite at a time, collect all currently knowable target-access facts through supported deterministic status/diagnosis surfaces. When installed Deployment Kit exposes the access diagnosis command, prefer:
284
+
285
+ ```text
286
+ project-forge-deploy access diagnose <target> --json
287
+ ```
288
+
289
+ If stable name resolution is unavailable and the developer supplies an exact address independently observed at the trusted target console, carry that address through read-only diagnosis without persisting it:
290
+
291
+ ```text
292
+ project-forge-deploy access diagnose <target> --address <exact-ip> --json
293
+ ```
294
+
295
+ The exact IP is address-path evidence, not a replacement SSH identity. Preserve the configured `HostKeyAlias`, target-specific trust, and configured identity. Do not store the address merely because it made diagnosis possible. Persist it only through the separately gated verified-address operation after strict SSH succeeds.
296
+
297
+ When diagnosis exposes route-source evidence, use the actual selected local source address/interface for any source-policy discussion. Never assume Wi-Fi, Ethernet, the first interface, or a subnet merely from workstation configuration.
298
+
299
+ If the Deployment Kit version does not expose the supported diagnosis surface, use its documented structured environment status/plan commands. Do not substitute ad hoc Python imports or unbounded raw `ssh -vvv` transcripts as normal orchestration.
300
+
269
301
  ### Guided remote failure recovery is part of `/setup`
270
302
 
271
303
  A supported deterministic setup/readiness failure is not, by itself, the end of `/setup`. When installed Deployment Kit tooling returns structured diagnosis or a bounded next action, continue through that diagnosis automatically until one of these boundaries is reached:
@@ -278,7 +310,7 @@ A supported deterministic setup/readiness failure is not, by itself, the end of
278
310
 
279
311
  Do **not** merely repeat an opaque subprocess exit code and tell the developer to fix access independently when deterministic diagnosis is available.
280
312
 
281
- For SSH target-access failure, prefer the Deployment Kit's structured environment setup status/plan evidence. Preserve distinct categories such as:
313
+ Preserve distinct categories such as:
282
314
 
283
315
  ```text
284
316
  name resolution / address path
@@ -293,8 +325,45 @@ remote account/source-policy rejection
293
325
 
294
326
  Keep credentials, private keys, raw secure-prompt content, and unbounded SSH transcripts out of conversational diagnostics and journals.
295
327
 
328
+ For fingerprint input, show the expected public form such as `SHA256:<fingerprint>` and validate that shape before progressing. Distinguish the **server host fingerprint**, the **client public-key fingerprint**, and passwords/passphrases. If input looks credential-like rather than fingerprint-shaped, do not repeat it into commands, reports, journals, or upstream issues.
329
+
330
+ If a local key/agent operation requires a passphrase or another terminal-owned secret prompt, follow the interactive-terminal handoff contract: collect any distinct bounded authorization once, then immediately provide the exact standalone copyable command, correct machine/working directory, expected local prompt, and the non-secret completion evidence to report. Do not ask for the passphrase through chat or ask a second ceremonial question merely to repeat the same authorized command.
331
+
296
332
  If client-side evidence shows that the configured key reached the server but access is still rejected, stop reinstalling the same key. Route to a trusted target console or already-authorized administrative session to inspect effective account/source policy. Do not guess that `AllowUsers`, `AllowGroups`, PAM, or another server rule is the exact cause until server-side evidence establishes it.
297
333
 
334
+ ### One bounded trusted-console diagnostic bundle
335
+
336
+ When client evidence reaches the server but cannot establish why the account/session is rejected, request one compact server-side evidence bundle instead of a chain of one-fact-at-a-time questions. Limit the bundle to the facts needed for the selected account and actual route-selected source:
337
+
338
+ - intended SSH account;
339
+ - whether the expected **client public-key fingerprint** is present for that account, without dumping unrelated authorized keys;
340
+ - ownership/modes for the account's `.ssh` directory and `authorized_keys`;
341
+ - a narrowly scoped recent authentication reason for the attempted source/account;
342
+ - effective SSH allow/deny policy relevant to that account and source;
343
+ - the file/include/`Match` location that owns any relevant directive.
344
+
345
+ State exactly where to run each console command and what non-secret output to return. Never request full SSH configs/logs, passwords, private keys, tokens, or unrelated authorized-key material when the focused bundle is enough.
346
+
347
+ Client evidence may classify `remote-authentication-rejected` or `remote-account-policy-rejected`, but only trusted server-side evidence may establish a concrete cause such as `AllowUsers`.
348
+
349
+ ### One exact server-policy repair after evidence
350
+
351
+ If the trusted-console evidence establishes that a server-side account/source policy change is required, prepare **one exact repair plan** before any write. The plan must show:
352
+
353
+ - exact affected file/directive and relevant before/after state;
354
+ - the actual durable source address/interface assumption being authorized;
355
+ - preservation of existing allow/deny entries unless the operator explicitly chooses otherwise;
356
+ - an exact backup of the affected file before editing;
357
+ - syntax/config validation before reload;
358
+ - preservation of the current trusted administrative session until new strict access is proven;
359
+ - reload/restart scope, rollback command, and explicit stop conditions.
360
+
361
+ Do not present configuration text as if it were a shell command. Give either one copyable bounded command or explicit editor instructions on the **target host**, with the file path stated. Do not assume that adding a new drop-in overrides or composes safely with existing includes/`Match` blocks. Never broaden a specific source restriction to a subnet merely for convenience.
362
+
363
+ A server-policy write remains a distinct operator/administrative authorization. Once the exact reviewed repair is authorized and the human reports it completed, do not ask the same authorization again: automatically re-run strict structured diagnosis/verification, then continue through separately gated address persistence, host-platform readiness, and proof as applicable.
364
+
365
+ If the human says stop, done, or pause at any point, stop modifying state. Read-only reporting may summarize what is already known, but newly observed prerequisite changes do not override the expressed pause.
366
+
298
367
  ### Gate password-assisted bootstrap on server capability
299
368
 
300
369
  Before recommending a password-assisted/Home Lab bootstrap against a reachable server, use deterministic evidence of the server-advertised authentication methods when the installed Deployment Kit supports that probe.
@@ -355,6 +424,8 @@ For a Production-class environment, keep the proof explicitly labeled as a **PRO
355
424
 
356
425
  If cleanup fails, report `PROOF PASSED — CLEANUP ACTION NEEDED`; do not claim a clean setup.
357
426
 
427
+ When proof output distinguishes `transferState: transferred` from `transferState: already-present`, report that distinction. A proof that reused an exact cached image may still prove runtime/health/cleanup, but it must not be described as an observed fresh workstation-to-target image transfer. If fresh transport itself must be tested, plan that separately without deleting or pruning a shared image cache merely to manufacture evidence.
428
+
358
429
  When installed Deployment Kit provides a durable local proof receipt or equivalent discoverable evidence, record/reuse it rather than relying on chat history. Reuse only while its target binding, host identity, workstation identity, adapter/provenance, logical environment, and cleanup result remain applicable. A new chat alone must not invalidate valid machine evidence.
359
430
 
360
431
  ## 9. `repair` is bounded and fail-closed
@@ -382,9 +453,27 @@ Stop for a separate explicit decision before:
382
453
 
383
454
  ## 11. Completion and handoff
384
455
 
385
- After setup, report evidence rather than inferring human acceptance.
456
+ After setup, report evidence rather than inferring human acceptance. Keep independent readiness dimensions independent instead of collapsing them into one `READY` label.
457
+
458
+ Prefer a compact final shape such as:
459
+
460
+ ```text
461
+ SETUP RESULT
462
+
463
+ Local tools/runtime ......... VERIFIED / BLOCKED / NOT APPLICABLE
464
+ Fresh-shell discovery ....... VERIFIED / CAVEAT / BLOCKED
465
+ Build/tests ................. VERIFIED / BLOCKED / NOT RUN
466
+ Remote target access ........ VERIFIED / BLOCKED / NOT CONFIGURED
467
+ Host platform ............... READY / BLOCKED / NOT APPLICABLE
468
+ Deployment path ............. PROVEN / NOT PROVEN / BLOCKED
469
+ Proof image transfer ........ TRANSFERRED / CACHE REUSED / NOT RUN
470
+ Proof cleanup ............... CLEAN / ACTION NEEDED / NOT RUN
471
+ Project application ......... NOT DEPLOYED / <actual evidence>
472
+ Repository CI ............... PASS / BLOCKED / NOT CHECKED
473
+ Human workspace acceptance .. CONFIRMED / PENDING
474
+ ```
386
475
 
387
- A generated setup/doctor/proof success is machine evidence. Human workspace acceptance remains distinct.
476
+ A generated setup/doctor/proof success is machine evidence. Human workspace acceptance remains distinct. A continuation/override for a documented Project Forge defect is not a CI waiver and is not human workspace acceptance; preserve its exact scope so resuming setup does not repeatedly ask the same decision.
388
477
 
389
478
  Useful next actions include:
390
479
  - `/next-step` — one sensible next action after current evidence is known;
@@ -393,4 +482,4 @@ Useful next actions include:
393
482
  - `/deploy <environment>` — only after readiness and its own authorization contract;
394
483
  - `/tear-down <scope>` — destructive cleanup under its separate gate.
395
484
 
396
- Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
485
+ Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
@@ -0,0 +1,65 @@
1
+ """CLI for strict target-access diagnosis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ from .access_diagnosis import diagnose_target_access
11
+ from .errors import DeploymentKitError
12
+
13
+
14
+ def _print(value: Any, *, as_json: bool) -> None:
15
+ if as_json:
16
+ print(json.dumps(value, indent=2, sort_keys=True))
17
+ return
18
+ if isinstance(value, dict):
19
+ for key, item in value.items():
20
+ if isinstance(item, (dict, list)):
21
+ print(f"{key}: {json.dumps(item, sort_keys=True)}")
22
+ else:
23
+ print(f"{key}: {item}")
24
+ return
25
+ print(value)
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="project-forge-deploy access",
31
+ description="Read-only strict SSH target-access diagnosis.",
32
+ )
33
+ subparsers = parser.add_subparsers(dest="command", required=True)
34
+
35
+ diagnose = subparsers.add_parser(
36
+ "diagnose",
37
+ help="Diagnose one configured target without mutating target or workstation state",
38
+ )
39
+ diagnose.add_argument("name")
40
+ diagnose.add_argument(
41
+ "--address",
42
+ help=(
43
+ "Exact temporary IPv4/IPv6 address independently confirmed by the operator. "
44
+ "Used only for this read-only diagnosis and never persisted."
45
+ ),
46
+ )
47
+ diagnose.add_argument("--json", action="store_true")
48
+ return parser
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ args = build_parser().parse_args(argv)
53
+ try:
54
+ if args.command == "diagnose":
55
+ result = diagnose_target_access(args.name, temporary_address=args.address)
56
+ _print(result, as_json=args.json)
57
+ return 0 if result.get("state") == "verified" else 2
58
+ raise DeploymentKitError("Unsupported access command.")
59
+ except DeploymentKitError as error:
60
+ print(f"ERROR: {error}", file=sys.stderr)
61
+ return 2
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """Read-only strict target-access diagnosis with optional temporary address evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import os
7
+ import re
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ from typing import Any
13
+
14
+ from .errors import DeploymentKitError
15
+ from .profiles import load_profile
16
+ from .ssh import diagnose_access
17
+
18
+
19
+ def _temporary_address(value: str | None) -> str | None:
20
+ if value is None:
21
+ return None
22
+ try:
23
+ parsed = ipaddress.ip_address(value)
24
+ except ValueError as error:
25
+ raise DeploymentKitError("Temporary target address must be an exact IPv4 or IPv6 address.") from error
26
+ if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
27
+ raise DeploymentKitError("Temporary target address must not include an IPv6 scope identifier.")
28
+ return str(parsed)
29
+
30
+
31
+ def _route_interface(address: str) -> str | None:
32
+ if sys.platform == "darwin":
33
+ route = shutil.which("route") or ("/sbin/route" if os.path.exists("/sbin/route") else None)
34
+ if route:
35
+ result = subprocess.run(
36
+ [route, "-n", "get", address],
37
+ check=False,
38
+ capture_output=True,
39
+ text=True,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ )
43
+ if result.returncode == 0:
44
+ match = re.search(r"^\s*interface:\s*(\S+)\s*$", result.stdout, re.MULTILINE)
45
+ if match:
46
+ return match.group(1)
47
+ elif os.name != "nt":
48
+ ip = shutil.which("ip")
49
+ if ip:
50
+ result = subprocess.run(
51
+ [ip, "route", "get", address],
52
+ check=False,
53
+ capture_output=True,
54
+ text=True,
55
+ encoding="utf-8",
56
+ errors="replace",
57
+ )
58
+ if result.returncode == 0:
59
+ match = re.search(r"\bdev\s+(\S+)", result.stdout)
60
+ if match:
61
+ return match.group(1)
62
+ return None
63
+
64
+
65
+ def route_source_evidence(address: str, port: int) -> dict[str, Any]:
66
+ """Return the local route-selected source for one exact target address without scanning."""
67
+ parsed = ipaddress.ip_address(address)
68
+ family = socket.AF_INET6 if isinstance(parsed, ipaddress.IPv6Address) else socket.AF_INET
69
+ evidence: dict[str, Any] = {
70
+ "targetAddress": str(parsed),
71
+ "targetPort": int(port),
72
+ "sourceAddress": None,
73
+ "interface": _route_interface(str(parsed)),
74
+ }
75
+ try:
76
+ with socket.socket(family, socket.SOCK_DGRAM) as probe:
77
+ probe.connect((str(parsed), int(port)))
78
+ evidence["sourceAddress"] = probe.getsockname()[0]
79
+ except OSError as error:
80
+ evidence["sourceProbeError"] = str(error)
81
+ return evidence
82
+
83
+
84
+ def diagnose_target_access(name: str, *, temporary_address: str | None = None) -> dict[str, Any]:
85
+ """Diagnose strict SSH access without persisting a temporary address override."""
86
+ profile = load_profile(name)
87
+ address = _temporary_address(temporary_address)
88
+ effective = dict(profile)
89
+ if address is not None:
90
+ effective["addressOverride"] = address
91
+
92
+ diagnosis = diagnose_access(effective)
93
+ used_address = diagnosis.get("address")
94
+ if used_address is None and address is not None:
95
+ used_address = address
96
+
97
+ result: dict[str, Any] = {
98
+ **diagnosis,
99
+ "addressInput": (
100
+ {
101
+ "source": "temporary-operator-confirmed",
102
+ "address": address,
103
+ "persisted": False,
104
+ }
105
+ if address is not None
106
+ else {
107
+ "source": "profile",
108
+ "address": profile.get("addressOverride"),
109
+ "persisted": profile.get("addressOverride") is not None,
110
+ }
111
+ ),
112
+ "mutated": False,
113
+ }
114
+ if used_address is not None:
115
+ result["routeSource"] = route_source_evidence(used_address, int(profile["port"]))
116
+ return result
@@ -12,6 +12,7 @@ from .app_deployment import (
12
12
  apply_app_deploy,
13
13
  apply_app_deploy_from_workstation,
14
14
  plan_app_deploy,
15
+ plan_app_rollout,
15
16
  )
16
17
  from .app_runtime import (
17
18
  APP_DEPLOY_WRITE_PHRASE,
@@ -85,6 +86,29 @@ def _add_isolation_footprint(parser: argparse.ArgumentParser) -> None:
85
86
  )
86
87
 
87
88
 
89
+ def _add_runtime_contract(parser: argparse.ArgumentParser) -> None:
90
+ parser.add_argument("--image", required=True)
91
+ parser.add_argument("--health-path", required=True)
92
+ parser.add_argument("--run-as", required=True)
93
+ parser.add_argument("--data-container-path")
94
+ parser.add_argument(
95
+ "--env",
96
+ action="append",
97
+ default=[],
98
+ help="Non-secret runtime environment value in NAME=value form; repeat as needed",
99
+ )
100
+ parser.add_argument(
101
+ "--image-source",
102
+ choices=("remote-registry", "workstation"),
103
+ default="remote-registry",
104
+ help=(
105
+ "remote-registry preserves the existing target-side pull path; workstation streams an exact local image "
106
+ "over verified SSH so the target does not need registry credentials"
107
+ ),
108
+ )
109
+ parser.add_argument("--json", action="store_true")
110
+
111
+
88
112
  def build_parser() -> argparse.ArgumentParser:
89
113
  parser = argparse.ArgumentParser(prog="project-forge-deploy app")
90
114
  sub = parser.add_subparsers(dest="command", required=True)
@@ -108,31 +132,19 @@ def build_parser() -> argparse.ArgumentParser:
108
132
  )
109
133
  isolation.add_argument("--json", action="store_true")
110
134
 
135
+ rollout = sub.add_parser(
136
+ "rollout",
137
+ help="Preview application runtime and owned route as one read-only staged rollout",
138
+ )
139
+ _add_isolation_footprint(rollout)
140
+ _add_runtime_contract(rollout)
141
+
111
142
  deploy = sub.add_parser(
112
143
  "deploy",
113
144
  help="Deploy one immutable application runtime without modifying routes",
114
145
  )
115
146
  _add_isolation_footprint(deploy)
116
- deploy.add_argument("--image", required=True)
117
- deploy.add_argument("--health-path", required=True)
118
- deploy.add_argument("--run-as", required=True)
119
- deploy.add_argument("--data-container-path")
120
- deploy.add_argument(
121
- "--env",
122
- action="append",
123
- default=[],
124
- help="Non-secret runtime environment value in NAME=value form; repeat as needed",
125
- )
126
- deploy.add_argument(
127
- "--image-source",
128
- choices=("remote-registry", "workstation"),
129
- default="remote-registry",
130
- help=(
131
- "remote-registry preserves the existing target-side pull path; workstation streams an exact local image "
132
- "over verified SSH so the target does not need registry credentials"
133
- ),
134
- )
135
- deploy.add_argument("--json", action="store_true")
147
+ _add_runtime_contract(deploy)
136
148
  deploy.add_argument("--yes", action="store_true")
137
149
  return parser
138
150
 
@@ -175,6 +187,20 @@ def main(argv: list[str] | None = None) -> int:
175
187
  environment_variables=_parse_env(args.env),
176
188
  )
177
189
  assert_app_spec_matches_persistence(args.name, spec)
190
+
191
+ if args.command == "rollout":
192
+ rollout_plan = plan_app_rollout(
193
+ args.name,
194
+ spec,
195
+ production=production_classified,
196
+ database_file=args.database_file,
197
+ route_hostnames=args.route_hostname,
198
+ )
199
+ rollout_plan["imageSource"] = args.image_source
200
+ rollout_plan["targetRegistryCredentialRequired"] = args.image_source == "remote-registry"
201
+ _print(rollout_plan, as_json=args.json)
202
+ return 0
203
+
178
204
  plan = plan_app_deploy(
179
205
  args.name,
180
206
  spec,