@mriekki/project-forge 4.2.0 → 4.4.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,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
+ }
@@ -0,0 +1,85 @@
1
+ """CLI for deterministic project-owned deployment topology binding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .errors import DeploymentKitError
12
+ from .project_topology import apply_environment_binding, plan_environment_binding
13
+
14
+
15
+ def _print(value: Any, *, as_json: bool) -> None:
16
+ if as_json:
17
+ print(json.dumps(value, indent=2, sort_keys=True))
18
+ return
19
+ if isinstance(value, dict):
20
+ for key, item in value.items():
21
+ if isinstance(item, (dict, list)):
22
+ print(f"{key}: {json.dumps(item, sort_keys=True)}")
23
+ else:
24
+ print(f"{key}: {item}")
25
+ return
26
+ print(value)
27
+
28
+
29
+ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
30
+ if args.yes:
31
+ return
32
+ entered = input(f"Type {phrase} to {action}: ").strip()
33
+ if entered != phrase:
34
+ raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(prog="project-forge-deploy topology")
39
+ sub = parser.add_subparsers(dest="command", required=True)
40
+
41
+ for command in ("plan-binding", "bind"):
42
+ item = sub.add_parser(
43
+ command,
44
+ help=(
45
+ "Preview completion of one deferred remote-target binding"
46
+ if command == "plan-binding"
47
+ else "Persist one reviewed deferred remote-target binding"
48
+ ),
49
+ )
50
+ item.add_argument("environment")
51
+ item.add_argument("--target-profile", required=True)
52
+ item.add_argument("--root", type=Path, default=Path("."))
53
+ item.add_argument("--json", action="store_true")
54
+ if command == "bind":
55
+ item.add_argument("--yes", action="store_true")
56
+ return parser
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ args = build_parser().parse_args(argv)
61
+ try:
62
+ plan = plan_environment_binding(args.root, args.environment, args.target_profile)
63
+ if args.command == "plan-binding":
64
+ _print(plan, as_json=args.json)
65
+ return 0
66
+ if plan["status"] == "already-bound":
67
+ _print(plan, as_json=args.json)
68
+ return 0
69
+ if not args.json:
70
+ _print(plan, as_json=False)
71
+ _confirm(
72
+ args,
73
+ plan["requiredAuthorization"],
74
+ f"bind project environment {args.environment} to target profile {args.target_profile}",
75
+ )
76
+ result = apply_environment_binding(plan)
77
+ _print(result, as_json=args.json)
78
+ return 0
79
+ except DeploymentKitError as error:
80
+ print(f"ERROR: {error}", file=sys.stderr)
81
+ return 2
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {