@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.
- package/assets/components.json +3 -3
- package/assets/deployment/distribution.json +5 -0
- package/assets/generated/ASSEMBLY.json +37 -12
- package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +25 -1
- package/assets/generated/core/core/skills/deploy/SKILL.md +15 -3
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +45 -1
- package/assets/generated/core/core/skills/setup/SKILL.md +21 -1
- package/assets/generated/deployment/docs/PROJECT_TOPOLOGY_BINDING.md +44 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +72 -21
- package/assets/generated/deployment/src/project_forge_deployment/app_deployment.py +194 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +60 -1
- package/assets/generated/deployment/src/project_forge_deployment/environment_isolation.py +438 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +57 -1
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +72 -26
- package/assets/generated/deployment/src/project_forge_deployment/project_topology.py +218 -0
- package/assets/generated/deployment/src/project_forge_deployment/topology_cli.py +85 -0
- package/package.json +1 -1
|
@@ -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())
|