@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.
@@ -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 .ssh import verify_access
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
- 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
- )
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": current,
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
+ }