@mriekki/project-forge 4.1.0 → 4.3.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 +4 -0
- package/assets/generated/ASSEMBLY.json +33 -13
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +3 -1
- package/assets/generated/core/core/skills/setup/SKILL.md +82 -1
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +137 -18
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +26 -0
- package/assets/generated/deployment/docs/PROJECT_TOPOLOGY_BINDING.md +44 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +56 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +7 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +106 -24
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +22 -1
- package/assets/generated/deployment/src/project_forge_deployment/project_topology.py +218 -0
- package/assets/generated/deployment/src/project_forge_deployment/proof_receipt.py +183 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +277 -23
- package/assets/generated/deployment/src/project_forge_deployment/topology_cli.py +85 -0
- package/package.json +1 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Project topology target binding
|
|
2
|
+
|
|
3
|
+
Project Forge keeps reusable non-secret target definitions in the developer's personal profile while each Project owns its logical environment-to-target binding in `DEPLOYMENT_TOPOLOGY.json`.
|
|
4
|
+
|
|
5
|
+
For an existing environment whose durable intent is already `placement: remote-target`, `adapter: remote-ssh`, and `bindingState: deferred`, Deployment Kit can preview and persist one target binding without rewriting Bootstrap provenance or other topology fields.
|
|
6
|
+
|
|
7
|
+
## Preview
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
project-forge-deploy topology plan-binding <environment> \
|
|
11
|
+
--target-profile <logical-target> \
|
|
12
|
+
--root . \
|
|
13
|
+
--json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The preview:
|
|
17
|
+
|
|
18
|
+
- reads the current project-owned topology;
|
|
19
|
+
- requires the named environment to already be a remote-target/remote-ssh environment;
|
|
20
|
+
- loads the selected user-scoped target profile;
|
|
21
|
+
- checks declared target OS compatibility;
|
|
22
|
+
- shows the exact before/after environment record;
|
|
23
|
+
- changes only `bindingState` and `targetProfile`;
|
|
24
|
+
- records a topology hash so a later write fails if the file changed after preview;
|
|
25
|
+
- never deploys an application, runs a proof, or changes target SSH/profile state.
|
|
26
|
+
|
|
27
|
+
A configured environment bound to another target is not eligible for this command. Rebinding requires a separate explicit topology-change contract.
|
|
28
|
+
|
|
29
|
+
## Apply
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
project-forge-deploy topology bind <environment> \
|
|
33
|
+
--target-profile <logical-target> \
|
|
34
|
+
--root .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The write requires the bounded phrase reported by the plan:
|
|
38
|
+
|
|
39
|
+
- `[PROJECT TOPOLOGY WRITE]` for non-Production environments;
|
|
40
|
+
- `[PROJECT PRODUCTION TOPOLOGY WRITE]` for Production-class environments.
|
|
41
|
+
|
|
42
|
+
Production classification comes from the existing topology role. The command never infers a Production target and this authorization is only for project configuration; it does **not** authorize Production proof or application deployment.
|
|
43
|
+
|
|
44
|
+
After a successful write, callers should re-read `DEPLOYMENT_TOPOLOGY.json` and continue normal environment setup/readiness from the selected logical target.
|
|
@@ -11,6 +11,7 @@ from .local_cli import main as local_main
|
|
|
11
11
|
from .registry_cli import main as registry_main
|
|
12
12
|
from .sqlite_cli import main as sqlite_main
|
|
13
13
|
from .teardown_cli import main as teardown_main
|
|
14
|
+
from .topology_cli import main as topology_main
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -27,6 +28,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
27
28
|
return sqlite_main(args[1:])
|
|
28
29
|
if args and args[0] == "tear-down":
|
|
29
30
|
return teardown_main(args[1:])
|
|
31
|
+
if args and args[0] == "topology":
|
|
32
|
+
return topology_main(args[1:])
|
|
30
33
|
if not args or args == ["--help"] or args == ["-h"]:
|
|
31
34
|
print("Command group: app Remote immutable application runtime inventory/deployment")
|
|
32
35
|
print("Command group: environment Portable profiles, Home Lab onboarding, and image transfer")
|
|
@@ -34,4 +37,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
34
37
|
print("Command group: registry Read-only exact-image registry access verification")
|
|
35
38
|
print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
|
|
36
39
|
print("Command group: tear-down Scoped destructive Project environment/local cleanup")
|
|
40
|
+
print("Command group: topology Project-owned logical environment binding")
|
|
37
41
|
return core_main(args)
|
|
@@ -56,6 +56,32 @@ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
|
56
56
|
raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def _setup_readiness(result: dict[str, Any], *, require_deployment_path: bool) -> dict[str, Any]:
|
|
60
|
+
required_phases = ["workspace", "targetDefinition", "targetAccess", "hostPlatform"]
|
|
61
|
+
expected_states = {
|
|
62
|
+
"workspace": "ready",
|
|
63
|
+
"targetDefinition": "ready",
|
|
64
|
+
"targetAccess": "ready",
|
|
65
|
+
"hostPlatform": "ready",
|
|
66
|
+
}
|
|
67
|
+
if require_deployment_path:
|
|
68
|
+
required_phases.append("deploymentPath")
|
|
69
|
+
expected_states["deploymentPath"] = "proven"
|
|
70
|
+
|
|
71
|
+
not_ready = [
|
|
72
|
+
phase
|
|
73
|
+
for phase in required_phases
|
|
74
|
+
if result["phases"][phase]["state"] != expected_states[phase]
|
|
75
|
+
]
|
|
76
|
+
return {
|
|
77
|
+
"ready": not not_ready,
|
|
78
|
+
"requiredThrough": "deploymentPath" if require_deployment_path else "hostPlatform",
|
|
79
|
+
"requiredPhases": required_phases,
|
|
80
|
+
"notReadyPhases": not_ready,
|
|
81
|
+
"applicationRequired": False,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
59
85
|
def build_parser() -> argparse.ArgumentParser:
|
|
60
86
|
parser = argparse.ArgumentParser(prog="project-forge-deploy environment")
|
|
61
87
|
groups = parser.add_subparsers(dest="group", required=True)
|
|
@@ -68,6 +94,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
68
94
|
setup_status = setup_sub.add_parser("status", help="Show one terminal readiness model for a remote target")
|
|
69
95
|
setup_status.add_argument("name")
|
|
70
96
|
setup_status.add_argument("--json", action="store_true")
|
|
97
|
+
setup_check = setup_sub.add_parser(
|
|
98
|
+
"check",
|
|
99
|
+
help="Return success only when the requested remote setup readiness contract is satisfied",
|
|
100
|
+
)
|
|
101
|
+
setup_check.add_argument("name")
|
|
102
|
+
setup_check.add_argument("--owner-repo")
|
|
103
|
+
setup_check.add_argument("--environment")
|
|
104
|
+
setup_check.add_argument(
|
|
105
|
+
"--production",
|
|
106
|
+
action="store_true",
|
|
107
|
+
help="Require proof evidence classified as Production; requires owner/environment proof context",
|
|
108
|
+
)
|
|
109
|
+
setup_check.add_argument("--json", action="store_true")
|
|
71
110
|
setup_plan = setup_sub.add_parser("plan", help="Show the exact next deterministic remote setup action")
|
|
72
111
|
setup_plan.add_argument("name")
|
|
73
112
|
setup_plan.add_argument("--owner-repo")
|
|
@@ -201,6 +240,23 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
201
240
|
if args.group == "setup" and args.command == "status":
|
|
202
241
|
_print(remote_setup_status(args.name), as_json=args.json)
|
|
203
242
|
return 0
|
|
243
|
+
if args.group == "setup" and args.command == "check":
|
|
244
|
+
if args.production and (args.owner_repo is None or args.environment is None):
|
|
245
|
+
raise DeploymentKitError(
|
|
246
|
+
"--production requires --owner-repo and --environment for setup readiness checking."
|
|
247
|
+
)
|
|
248
|
+
result = remote_setup_plan(
|
|
249
|
+
args.name,
|
|
250
|
+
owner_repo=args.owner_repo,
|
|
251
|
+
environment=args.environment,
|
|
252
|
+
production=args.production,
|
|
253
|
+
)
|
|
254
|
+
readiness = _setup_readiness(
|
|
255
|
+
result,
|
|
256
|
+
require_deployment_path=args.owner_repo is not None,
|
|
257
|
+
)
|
|
258
|
+
_print({**result, "readinessCheck": readiness}, as_json=args.json)
|
|
259
|
+
return 0 if readiness["ready"] else 2
|
|
204
260
|
if args.group == "setup" and args.command == "plan":
|
|
205
261
|
_print(
|
|
206
262
|
remote_setup_plan(
|
|
@@ -14,6 +14,7 @@ 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
16
|
from .image_transfer import remote_image_present, transfer_image, verify_local_image
|
|
17
|
+
from .proof_receipt import write_environment_proof_receipt
|
|
17
18
|
from .registry_access import normalize_registry_image
|
|
18
19
|
from .teardown import tear_down_project
|
|
19
20
|
|
|
@@ -152,6 +153,8 @@ def _proof_result(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
152
153
|
"finalMutationState": "none",
|
|
153
154
|
"error": None,
|
|
154
155
|
"cleanupError": None,
|
|
156
|
+
"receipt": None,
|
|
157
|
+
"receiptError": None,
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
|
|
@@ -298,4 +301,8 @@ def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
|
|
|
298
301
|
result["error"] = "Environment proof cleanup did not return terminal runtime cleanup evidence."
|
|
299
302
|
|
|
300
303
|
result["finalMutationState"] = _final_mutation_state(result)
|
|
304
|
+
try:
|
|
305
|
+
result["receipt"] = write_environment_proof_receipt(result)
|
|
306
|
+
except Exception as error: # proof result stays truthful even if local evidence persistence fails
|
|
307
|
+
result["receiptError"] = str(error)
|
|
301
308
|
return result
|
|
@@ -14,7 +14,8 @@ from .doctor import target_doctor, workstation_doctor
|
|
|
14
14
|
from .environment_probe import plan_environment_proof
|
|
15
15
|
from .errors import DeploymentKitError
|
|
16
16
|
from .profiles import load_profile
|
|
17
|
-
from .
|
|
17
|
+
from .proof_receipt import load_environment_proof_receipt
|
|
18
|
+
from .ssh import diagnose_access
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
READINESS_ORDER = (
|
|
@@ -34,6 +35,13 @@ def _phase(label: str, state: str, *, detail: Any = None) -> dict[str, Any]:
|
|
|
34
35
|
return value
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
def _current_phase(phases: dict[str, dict[str, Any]]) -> str:
|
|
39
|
+
return next(
|
|
40
|
+
(key for key in READINESS_ORDER if phases[key]["state"] not in {"ready", "proven", "not-part-of-setup"}),
|
|
41
|
+
"application",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
37
45
|
def remote_setup_status(name: str) -> dict[str, Any]:
|
|
38
46
|
"""Return one read-only, terminal status model for remote environment setup."""
|
|
39
47
|
|
|
@@ -57,19 +65,15 @@ def remote_setup_status(name: str) -> dict[str, Any]:
|
|
|
57
65
|
"application": _phase("APPLICATION DEPLOYED", "not-part-of-setup"),
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
target_access: dict[str, Any] | None = None
|
|
61
68
|
host_platform: dict[str, Any] | None = None
|
|
62
69
|
|
|
63
70
|
if target["state"] == "ready-to-verify":
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"verification-failed",
|
|
71
|
-
detail={"reason": str(error)},
|
|
72
|
-
)
|
|
71
|
+
target_access = diagnose_access(load_profile(name))
|
|
72
|
+
phases["targetAccess"] = _phase(
|
|
73
|
+
"TARGET ACCESS READY",
|
|
74
|
+
"ready" if target_access["state"] == "verified" else "diagnosis-required",
|
|
75
|
+
detail=target_access,
|
|
76
|
+
)
|
|
73
77
|
elif target["state"] == "needs-profile":
|
|
74
78
|
phases["targetAccess"] = _phase("TARGET ACCESS READY", "blocked-by-target-definition")
|
|
75
79
|
else:
|
|
@@ -96,19 +100,76 @@ def remote_setup_status(name: str) -> dict[str, Any]:
|
|
|
96
100
|
else:
|
|
97
101
|
phases["hostPlatform"] = _phase("HOST PLATFORM READY", "blocked-by-target-access")
|
|
98
102
|
|
|
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
103
|
return {
|
|
104
104
|
"targetName": name,
|
|
105
105
|
"state": "host-platform-ready" if phases["hostPlatform"]["state"] == "ready" else "needs-setup",
|
|
106
|
-
"currentPhase":
|
|
106
|
+
"currentPhase": _current_phase(phases),
|
|
107
107
|
"phases": phases,
|
|
108
108
|
"mutated": False,
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def _access_recovery(status: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
detail = status["phases"]["targetAccess"].get("detail") or {}
|
|
114
|
+
category = detail.get("failureCategory")
|
|
115
|
+
target = status["targetName"]
|
|
116
|
+
command = f"project-forge-deploy environment setup status {target} --json"
|
|
117
|
+
|
|
118
|
+
if category in {"remote-authentication-rejected", "remote-account-policy-rejected"}:
|
|
119
|
+
return {
|
|
120
|
+
"kind": "operator-action-required",
|
|
121
|
+
"action": "inspect-remote-account-access-policy",
|
|
122
|
+
"command": command,
|
|
123
|
+
"reason": detail.get("guidance"),
|
|
124
|
+
"safety": (
|
|
125
|
+
"Use a trusted target console or already-authorized administrative session. "
|
|
126
|
+
"Do not reinstall keys repeatedly, enable password SSH, widen AllowUsers, or weaken host verification implicitly."
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
if category == "local-signing-failed":
|
|
130
|
+
return {
|
|
131
|
+
"kind": "local-repair",
|
|
132
|
+
"action": "repair-local-key-signing",
|
|
133
|
+
"command": command,
|
|
134
|
+
"reason": detail.get("guidance"),
|
|
135
|
+
}
|
|
136
|
+
if category == "host-identity-rejected":
|
|
137
|
+
return {
|
|
138
|
+
"kind": "human-verification",
|
|
139
|
+
"action": "reverify-host-identity",
|
|
140
|
+
"command": f"project-forge-deploy target trust-candidates {target}",
|
|
141
|
+
"reason": detail.get("guidance"),
|
|
142
|
+
}
|
|
143
|
+
if category in {"transport-failed", "name-resolution-failed"}:
|
|
144
|
+
return {
|
|
145
|
+
"kind": "missing-facts",
|
|
146
|
+
"action": "repair-target-address-or-network-path",
|
|
147
|
+
"command": command,
|
|
148
|
+
"reason": detail.get("guidance") or detail.get("reason"),
|
|
149
|
+
}
|
|
150
|
+
if category == "missing-identity":
|
|
151
|
+
return {
|
|
152
|
+
"kind": "write-gated",
|
|
153
|
+
"action": "create-or-select-key",
|
|
154
|
+
"command": f"project-forge-deploy target key-create {target}",
|
|
155
|
+
"authorization": "[LOCAL KEY WRITE]",
|
|
156
|
+
"reason": detail.get("reason"),
|
|
157
|
+
}
|
|
158
|
+
if category == "missing-trust":
|
|
159
|
+
return {
|
|
160
|
+
"kind": "human-verification",
|
|
161
|
+
"action": "verify-and-enroll-host-trust",
|
|
162
|
+
"command": f"project-forge-deploy target trust-candidates {target}",
|
|
163
|
+
"reason": detail.get("reason"),
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
"kind": "read-only",
|
|
167
|
+
"action": "diagnose-target-access",
|
|
168
|
+
"command": command,
|
|
169
|
+
"reason": detail.get("guidance") or detail.get("reason"),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
112
173
|
def _next_action(status: dict[str, Any]) -> dict[str, Any]:
|
|
113
174
|
phases = status["phases"]
|
|
114
175
|
target = phases["targetDefinition"].get("detail") or {}
|
|
@@ -151,12 +212,7 @@ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
|
|
|
151
212
|
}
|
|
152
213
|
|
|
153
214
|
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
|
-
}
|
|
215
|
+
return _access_recovery(status)
|
|
160
216
|
|
|
161
217
|
platform = phases["hostPlatform"].get("detail") or {}
|
|
162
218
|
platform_state = phases["hostPlatform"]["state"]
|
|
@@ -188,6 +244,14 @@ def _next_action(status: dict[str, Any]) -> dict[str, Any]:
|
|
|
188
244
|
"reason": f"Host platform state is {platform_state}.",
|
|
189
245
|
}
|
|
190
246
|
|
|
247
|
+
if phases["deploymentPath"]["state"] == "proven":
|
|
248
|
+
return {
|
|
249
|
+
"kind": "complete",
|
|
250
|
+
"action": "remote-environment-ready",
|
|
251
|
+
"reason": "Fresh workstation-local proof evidence matches this Project/environment/target context.",
|
|
252
|
+
"proofReceipt": phases["deploymentPath"].get("detail"),
|
|
253
|
+
}
|
|
254
|
+
|
|
191
255
|
return {
|
|
192
256
|
"kind": "proof-context-required",
|
|
193
257
|
"action": "plan-deployment-path-proof",
|
|
@@ -204,7 +268,26 @@ def remote_setup_plan(
|
|
|
204
268
|
) -> dict[str, Any]:
|
|
205
269
|
"""Return the exact next deterministic setup action without mutating state."""
|
|
206
270
|
|
|
271
|
+
if (owner_repo is None) != (environment is None):
|
|
272
|
+
raise DeploymentKitError("ownerRepo and environment must be supplied together for setup proof planning.")
|
|
273
|
+
|
|
207
274
|
status = remote_setup_status(name)
|
|
275
|
+
if owner_repo is not None and environment is not None and status["phases"]["hostPlatform"]["state"] == "ready":
|
|
276
|
+
receipt = load_environment_proof_receipt(
|
|
277
|
+
name,
|
|
278
|
+
owner_repo=owner_repo,
|
|
279
|
+
environment=environment,
|
|
280
|
+
production=production,
|
|
281
|
+
)
|
|
282
|
+
status["phases"]["deploymentPath"] = _phase(
|
|
283
|
+
"DEPLOYMENT PATH PROVEN",
|
|
284
|
+
"proven" if receipt["state"] == "proven" else "not-proven",
|
|
285
|
+
detail=receipt,
|
|
286
|
+
)
|
|
287
|
+
status["currentPhase"] = _current_phase(status["phases"])
|
|
288
|
+
if receipt["state"] == "proven":
|
|
289
|
+
status["state"] = "environment-ready"
|
|
290
|
+
|
|
208
291
|
next_action = _next_action(status)
|
|
209
292
|
|
|
210
293
|
if (
|
|
@@ -230,9 +313,8 @@ def remote_setup_plan(
|
|
|
230
313
|
"command": command,
|
|
231
314
|
"proofPlan": proof,
|
|
232
315
|
"runAuthorization": proof["requiredAuthorization"],
|
|
316
|
+
"previousProof": status["phases"]["deploymentPath"].get("detail"),
|
|
233
317
|
}
|
|
234
|
-
elif (owner_repo is None) != (environment is None):
|
|
235
|
-
raise DeploymentKitError("ownerRepo and environment must be supplied together for setup proof planning.")
|
|
236
318
|
|
|
237
319
|
return {
|
|
238
320
|
**status,
|
|
@@ -14,7 +14,7 @@ from .keys import key_fingerprint, public_key_info, required_tool as required_ss
|
|
|
14
14
|
from .portable_profile import _machine_paths
|
|
15
15
|
from .profiles import load_profile, save_profile, target_profile_path, validate_profile, validate_target_name
|
|
16
16
|
from .security import check_identity_security, prepare_ssh_directory, validate_target_trust_file
|
|
17
|
-
from .ssh import verify_access
|
|
17
|
+
from .ssh import probe_authentication_methods, resolve_addresses, verify_access
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
HOME_LAB_BOOTSTRAP_PHRASE = "[HOME LAB ACCESS BOOTSTRAP]"
|
|
@@ -212,6 +212,23 @@ def _password_bootstrap_args(profile: dict[str, Any], *, first_use: bool, comman
|
|
|
212
212
|
]
|
|
213
213
|
|
|
214
214
|
|
|
215
|
+
def _require_password_bootstrap_capability(profile: dict[str, Any]) -> list[dict[str, Any]]:
|
|
216
|
+
"""Fail before password onboarding when a trusted server advertises key-only auth."""
|
|
217
|
+
|
|
218
|
+
evidence = [probe_authentication_methods(profile, address) for address in resolve_addresses(profile)]
|
|
219
|
+
observed = [item for item in evidence if item["observed"]]
|
|
220
|
+
if observed and not any(item["passwordSupported"] for item in observed):
|
|
221
|
+
advertised = sorted({method for item in observed for method in item["methods"]})
|
|
222
|
+
shown = ", ".join(advertised) if advertised else "none observed"
|
|
223
|
+
raise DeploymentKitError(
|
|
224
|
+
"Password-assisted Home Lab bootstrap is unavailable for this trusted target: "
|
|
225
|
+
f"the SSH server advertises {shown}, not password/keyboard-interactive authentication. "
|
|
226
|
+
"Use the Strict key-onboarding path and a trusted console or already-authorized session "
|
|
227
|
+
"for any required server-side key/account-policy change. Do not enable password SSH merely for bootstrap."
|
|
228
|
+
)
|
|
229
|
+
return evidence
|
|
230
|
+
|
|
231
|
+
|
|
215
232
|
def _install_public_key_with_password(profile: dict[str, Any], *, first_use: bool) -> None:
|
|
216
233
|
info = public_key_info(profile)
|
|
217
234
|
encoded = base64.b64encode(info["publicKey"].encode("utf-8")).decode("ascii")
|
|
@@ -316,6 +333,10 @@ def apply_home_lab_bootstrap(
|
|
|
316
333
|
except DeploymentKitError:
|
|
317
334
|
pass
|
|
318
335
|
|
|
336
|
+
# Existing trust lets us discover the server's advertised auth methods safely.
|
|
337
|
+
# Avoid a guaranteed-to-fail password prompt against public-key-only servers.
|
|
338
|
+
_require_password_bootstrap_capability(desired)
|
|
339
|
+
|
|
319
340
|
_install_public_key_with_password(desired, first_use=first_use)
|
|
320
341
|
validate_target_trust_file(desired)
|
|
321
342
|
evidence = verify_access(desired)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Deterministic mutation of project-owned deployment topology bindings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import DeploymentKitError
|
|
12
|
+
from .profiles import load_profile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TOPOLOGY_FILE = "DEPLOYMENT_TOPOLOGY.json"
|
|
16
|
+
TOPOLOGY_SCHEMA_VERSION = 1
|
|
17
|
+
PROJECT_TOPOLOGY_WRITE_PHRASE = "[PROJECT TOPOLOGY WRITE]"
|
|
18
|
+
PROJECT_PRODUCTION_TOPOLOGY_WRITE_PHRASE = "[PROJECT PRODUCTION TOPOLOGY WRITE]"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _topology_path(root: str | Path) -> Path:
|
|
22
|
+
project_root = Path(root).expanduser().resolve()
|
|
23
|
+
path = project_root / TOPOLOGY_FILE
|
|
24
|
+
if path.is_symlink() or not path.is_file():
|
|
25
|
+
raise DeploymentKitError(f"Project deployment topology is missing or unsafe: {path}")
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_topology(root: str | Path) -> tuple[Path, str, dict[str, Any]]:
|
|
30
|
+
path = _topology_path(root)
|
|
31
|
+
try:
|
|
32
|
+
raw = path.read_text(encoding="utf-8")
|
|
33
|
+
value = json.loads(raw)
|
|
34
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
35
|
+
raise DeploymentKitError(f"Cannot read project deployment topology {path}: {error}") from error
|
|
36
|
+
if not isinstance(value, dict) or value.get("schemaVersion") != TOPOLOGY_SCHEMA_VERSION:
|
|
37
|
+
raise DeploymentKitError("Unsupported or malformed project deployment topology schema.")
|
|
38
|
+
environments = value.get("environments")
|
|
39
|
+
if not isinstance(environments, list):
|
|
40
|
+
raise DeploymentKitError("Project deployment topology environments must be an array.")
|
|
41
|
+
names: list[str] = []
|
|
42
|
+
for item in environments:
|
|
43
|
+
if not isinstance(item, dict):
|
|
44
|
+
raise DeploymentKitError("Project deployment topology contains a non-object environment.")
|
|
45
|
+
name = item.get("name")
|
|
46
|
+
if not isinstance(name, str) or not name:
|
|
47
|
+
raise DeploymentKitError("Project deployment topology contains an environment without a valid name.")
|
|
48
|
+
names.append(name)
|
|
49
|
+
if len(names) != len(set(names)):
|
|
50
|
+
raise DeploymentKitError("Project deployment topology contains duplicate environment names.")
|
|
51
|
+
return path, raw, value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _environment(value: dict[str, Any], name: str) -> tuple[int, dict[str, Any]]:
|
|
55
|
+
for index, item in enumerate(value["environments"]):
|
|
56
|
+
if item.get("name") == name:
|
|
57
|
+
return index, item
|
|
58
|
+
raise DeploymentKitError(f"Project deployment topology does not define environment: {name}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _topology_sha(raw: str) -> str:
|
|
62
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def plan_environment_binding(
|
|
66
|
+
root: str | Path,
|
|
67
|
+
environment: str,
|
|
68
|
+
target_profile: str,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
"""Preview completion of one deferred remote-target binding without writing project state."""
|
|
71
|
+
|
|
72
|
+
path, raw, topology = _read_topology(root)
|
|
73
|
+
_, current = _environment(topology, environment)
|
|
74
|
+
|
|
75
|
+
role = current.get("role")
|
|
76
|
+
if role not in {"non-production", "production"}:
|
|
77
|
+
raise DeploymentKitError(f"Environment {environment} has an unsupported role: {role}")
|
|
78
|
+
if current.get("placement") != "remote-target" or current.get("adapter") != "remote-ssh":
|
|
79
|
+
raise DeploymentKitError(
|
|
80
|
+
f"Environment {environment} is not an existing remote-target/remote-ssh binding; "
|
|
81
|
+
"this command will not rewrite placement or adapter intent."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
profile = load_profile(target_profile)
|
|
85
|
+
target_os = profile.get("targetOs")
|
|
86
|
+
required_os = current.get("targetOs")
|
|
87
|
+
if required_os not in {None, "other"} and target_os != required_os:
|
|
88
|
+
raise DeploymentKitError(
|
|
89
|
+
f"Target profile {target_profile} uses targetOs={target_os}; environment {environment} requires {required_os}."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
state = current.get("bindingState")
|
|
93
|
+
existing_target = current.get("targetProfile")
|
|
94
|
+
production = role == "production"
|
|
95
|
+
authorization = (
|
|
96
|
+
PROJECT_PRODUCTION_TOPOLOGY_WRITE_PHRASE
|
|
97
|
+
if production
|
|
98
|
+
else PROJECT_TOPOLOGY_WRITE_PHRASE
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
before = deepcopy(current)
|
|
102
|
+
if state == "configured":
|
|
103
|
+
if existing_target != target_profile:
|
|
104
|
+
raise DeploymentKitError(
|
|
105
|
+
f"Environment {environment} is already configured for target {existing_target}; "
|
|
106
|
+
"rebinding requires a separate explicit topology-change contract."
|
|
107
|
+
)
|
|
108
|
+
return {
|
|
109
|
+
"status": "already-bound",
|
|
110
|
+
"projectRoot": str(path.parent),
|
|
111
|
+
"path": str(path),
|
|
112
|
+
"environment": environment,
|
|
113
|
+
"production": production,
|
|
114
|
+
"targetProfile": target_profile,
|
|
115
|
+
"targetOs": target_os,
|
|
116
|
+
"before": before,
|
|
117
|
+
"after": deepcopy(current),
|
|
118
|
+
"changedFields": [],
|
|
119
|
+
"requiredAuthorization": None,
|
|
120
|
+
"expectedTopologySha256": _topology_sha(raw),
|
|
121
|
+
"mutated": False,
|
|
122
|
+
}
|
|
123
|
+
if state != "deferred":
|
|
124
|
+
raise DeploymentKitError(
|
|
125
|
+
f"Environment {environment} has unsupported bindingState={state}; expected deferred or configured."
|
|
126
|
+
)
|
|
127
|
+
if existing_target is not None:
|
|
128
|
+
raise DeploymentKitError(
|
|
129
|
+
f"Deferred environment {environment} unexpectedly already contains targetProfile={existing_target}."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
after = deepcopy(current)
|
|
133
|
+
after["bindingState"] = "configured"
|
|
134
|
+
after["targetProfile"] = target_profile
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"status": "bind",
|
|
138
|
+
"projectRoot": str(path.parent),
|
|
139
|
+
"path": str(path),
|
|
140
|
+
"environment": environment,
|
|
141
|
+
"production": production,
|
|
142
|
+
"targetProfile": target_profile,
|
|
143
|
+
"targetOs": target_os,
|
|
144
|
+
"targetSummary": {
|
|
145
|
+
"targetName": profile.get("targetName"),
|
|
146
|
+
"targetOs": target_os,
|
|
147
|
+
"hostName": profile.get("hostName"),
|
|
148
|
+
"sshAlias": profile.get("sshAlias"),
|
|
149
|
+
},
|
|
150
|
+
"before": before,
|
|
151
|
+
"after": after,
|
|
152
|
+
"changedFields": ["bindingState", "targetProfile"],
|
|
153
|
+
"requiredAuthorization": authorization,
|
|
154
|
+
"expectedTopologySha256": _topology_sha(raw),
|
|
155
|
+
"mutated": False,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def apply_environment_binding(plan: dict[str, Any]) -> dict[str, Any]:
|
|
160
|
+
"""Apply exactly one previously previewed deferred binding, refusing stale topology state."""
|
|
161
|
+
|
|
162
|
+
if plan.get("status") == "already-bound":
|
|
163
|
+
return {
|
|
164
|
+
"status": "already-bound",
|
|
165
|
+
"environment": plan.get("environment"),
|
|
166
|
+
"targetProfile": plan.get("targetProfile"),
|
|
167
|
+
"production": bool(plan.get("production")),
|
|
168
|
+
"path": plan.get("path"),
|
|
169
|
+
"mutated": False,
|
|
170
|
+
}
|
|
171
|
+
required = {
|
|
172
|
+
"projectRoot",
|
|
173
|
+
"environment",
|
|
174
|
+
"targetProfile",
|
|
175
|
+
"before",
|
|
176
|
+
"after",
|
|
177
|
+
"expectedTopologySha256",
|
|
178
|
+
"requiredAuthorization",
|
|
179
|
+
}
|
|
180
|
+
if plan.get("status") != "bind" or any(key not in plan for key in required):
|
|
181
|
+
raise DeploymentKitError("Project topology binding plan is malformed.")
|
|
182
|
+
|
|
183
|
+
path, raw, topology = _read_topology(plan["projectRoot"])
|
|
184
|
+
if _topology_sha(raw) != plan["expectedTopologySha256"]:
|
|
185
|
+
raise DeploymentKitError(
|
|
186
|
+
"Project deployment topology changed after preview; re-run the binding plan before writing."
|
|
187
|
+
)
|
|
188
|
+
index, current = _environment(topology, plan["environment"])
|
|
189
|
+
if current != plan["before"]:
|
|
190
|
+
raise DeploymentKitError(
|
|
191
|
+
"Selected environment changed after preview; re-run the binding plan before writing."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
updated = deepcopy(topology)
|
|
195
|
+
updated["environments"][index] = deepcopy(plan["after"])
|
|
196
|
+
temp = path.with_name(path.name + ".tmp")
|
|
197
|
+
if temp.is_symlink():
|
|
198
|
+
raise DeploymentKitError(f"Refusing to write project topology through a symbolic link: {temp}")
|
|
199
|
+
try:
|
|
200
|
+
temp.write_text(json.dumps(updated, indent=2) + "\n", encoding="utf-8")
|
|
201
|
+
temp.replace(path)
|
|
202
|
+
except (OSError, UnicodeError) as error:
|
|
203
|
+
raise DeploymentKitError(f"Cannot persist project deployment topology {path}: {error}") from error
|
|
204
|
+
|
|
205
|
+
_, _, reread = _read_topology(plan["projectRoot"])
|
|
206
|
+
_, bound = _environment(reread, plan["environment"])
|
|
207
|
+
if bound != plan["after"]:
|
|
208
|
+
raise DeploymentKitError("Project deployment topology write did not persist the reviewed environment binding.")
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"status": "bound",
|
|
212
|
+
"environment": plan["environment"],
|
|
213
|
+
"targetProfile": plan["targetProfile"],
|
|
214
|
+
"production": bool(plan.get("production")),
|
|
215
|
+
"bindingState": "configured",
|
|
216
|
+
"path": str(path),
|
|
217
|
+
"mutated": True,
|
|
218
|
+
}
|