@mriekki/project-forge 4.2.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 +3 -0
- package/assets/generated/ASSEMBLY.json +21 -6
- 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/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/project_topology.py +218 -0
- package/assets/generated/deployment/src/project_forge_deployment/topology_cli.py +85 -0
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.3.0",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
16
|
"repository": "mriekki/project-forge-core",
|
|
17
|
-
"commit": "
|
|
17
|
+
"commit": "c35a42d4a87e59b3729797a9a2aace8499a93953",
|
|
18
18
|
"version": "4.0.0",
|
|
19
19
|
"distribution": "greenfield-workspace",
|
|
20
20
|
"descriptor": "assets/core/distribution.json",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"deployment": {
|
|
25
25
|
"repository": "mriekki/project-forge-deployment-kit",
|
|
26
|
-
"commit": "
|
|
26
|
+
"commit": "e5fe3333cad04714d714445866639680378eb23d",
|
|
27
27
|
"distribution": "greenfield-project",
|
|
28
28
|
"descriptor": "assets/deployment/distribution.json",
|
|
29
29
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
{"source": "src/project_forge_deployment/profile_migration.py", "destination": "tools/project-forge-deployment/project_forge_deployment/profile_migration.py"},
|
|
32
32
|
{"source": "src/project_forge_deployment/profile_repository.py", "destination": "tools/project-forge-deployment/project_forge_deployment/profile_repository.py"},
|
|
33
33
|
{"source": "src/project_forge_deployment/profiles.py", "destination": "tools/project-forge-deployment/project_forge_deployment/profiles.py"},
|
|
34
|
+
{"source": "src/project_forge_deployment/project_topology.py", "destination": "tools/project-forge-deployment/project_forge_deployment/project_topology.py"},
|
|
34
35
|
{"source": "src/project_forge_deployment/proof_receipt.py", "destination": "tools/project-forge-deployment/project_forge_deployment/proof_receipt.py"},
|
|
35
36
|
{"source": "src/project_forge_deployment/registry_access.py", "destination": "tools/project-forge-deployment/project_forge_deployment/registry_access.py"},
|
|
36
37
|
{"source": "src/project_forge_deployment/registry_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/registry_cli.py"},
|
|
@@ -45,10 +46,12 @@
|
|
|
45
46
|
{"source": "src/project_forge_deployment/teardown.py", "destination": "tools/project-forge-deployment/project_forge_deployment/teardown.py"},
|
|
46
47
|
{"source": "src/project_forge_deployment/teardown_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/teardown_cli.py"},
|
|
47
48
|
{"source": "src/project_forge_deployment/teardown_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/teardown_remote.py"},
|
|
49
|
+
{"source": "src/project_forge_deployment/topology_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/topology_cli.py"},
|
|
48
50
|
{"source": "src/project_forge_deployment/trust.py", "destination": "tools/project-forge-deployment/project_forge_deployment/trust.py"},
|
|
49
51
|
{"source": "docs/DEVELOPER_ENVIRONMENTS.md", "destination": "docs/project-forge/DEVELOPER_ENVIRONMENTS.md"},
|
|
50
52
|
{"source": "docs/WORKSPACE_INSTANCES.md", "destination": "docs/project-forge/WORKSPACE_INSTANCES.md"},
|
|
51
53
|
{"source": "docs/ENVIRONMENT_PROOF.md", "destination": "docs/project-forge/ENVIRONMENT_PROOF.md"},
|
|
54
|
+
{"source": "docs/PROJECT_TOPOLOGY_BINDING.md", "destination": "docs/project-forge/PROJECT_TOPOLOGY_BINDING.md"},
|
|
52
55
|
{"source": "docs/images/developer-environment-journey.svg", "destination": "docs/project-forge/images/developer-environment-journey.svg"},
|
|
53
56
|
{"source": "docs/images/personal-environment-architecture.svg", "destination": "docs/project-forge/images/personal-environment-architecture.svg"},
|
|
54
57
|
{"source": "docs/images/project-environment-model.svg", "destination": "docs/project-forge/images/project-environment-model.svg"}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.3.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"repository": "mriekki/project-forge-core",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "c35a42d4a87e59b3729797a9a2aace8499a93953",
|
|
9
9
|
"version": "4.0.0",
|
|
10
10
|
"distribution": "greenfield-workspace",
|
|
11
11
|
"files": [
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
{
|
|
53
53
|
"source": "core/skills/setup/SKILL.md",
|
|
54
54
|
"destination": ".github/skills/setup/SKILL.md",
|
|
55
|
-
"sha256": "
|
|
55
|
+
"sha256": "0ba13a009347742ede2c6e6121a21f29640a05864a994c7a51b4c992c0b2c619"
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"source": "core/skills/build/SKILL.md",
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
},
|
|
329
329
|
"deployment": {
|
|
330
330
|
"repository": "mriekki/project-forge-deployment-kit",
|
|
331
|
-
"commit": "
|
|
331
|
+
"commit": "e5fe3333cad04714d714445866639680378eb23d",
|
|
332
332
|
"distribution": "greenfield-project",
|
|
333
333
|
"files": [
|
|
334
334
|
{
|
|
@@ -394,12 +394,12 @@
|
|
|
394
394
|
{
|
|
395
395
|
"source": "src/project_forge_deployment/entrypoint.py",
|
|
396
396
|
"destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py",
|
|
397
|
-
"sha256": "
|
|
397
|
+
"sha256": "341e4682a050e9eea8c6ecf0e8728efacc57f06144d9eceb12ac37cf8fb2a3b4"
|
|
398
398
|
},
|
|
399
399
|
{
|
|
400
400
|
"source": "src/project_forge_deployment/environment_cli.py",
|
|
401
401
|
"destination": "tools/project-forge-deployment/project_forge_deployment/environment_cli.py",
|
|
402
|
-
"sha256": "
|
|
402
|
+
"sha256": "13d41913fd91ad2461bc28e1af0343dd81ba34141c0db8b30f20d89423b64582"
|
|
403
403
|
},
|
|
404
404
|
{
|
|
405
405
|
"source": "src/project_forge_deployment/environment_probe.py",
|
|
@@ -471,6 +471,11 @@
|
|
|
471
471
|
"destination": "tools/project-forge-deployment/project_forge_deployment/profiles.py",
|
|
472
472
|
"sha256": "c8fb1c378af9d39d28f46b300064bec967e2a6f7a13e22d089fc00413c42fd9d"
|
|
473
473
|
},
|
|
474
|
+
{
|
|
475
|
+
"source": "src/project_forge_deployment/project_topology.py",
|
|
476
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/project_topology.py",
|
|
477
|
+
"sha256": "72e810d5c6438529195069a5640c74e2774816c16bc6b0f5beff152710240bf7"
|
|
478
|
+
},
|
|
474
479
|
{
|
|
475
480
|
"source": "src/project_forge_deployment/proof_receipt.py",
|
|
476
481
|
"destination": "tools/project-forge-deployment/project_forge_deployment/proof_receipt.py",
|
|
@@ -541,6 +546,11 @@
|
|
|
541
546
|
"destination": "tools/project-forge-deployment/project_forge_deployment/teardown_remote.py",
|
|
542
547
|
"sha256": "ad764f5a67c43f81b4973dd5639b2c28b23ae883a85e5ae29efc14dad506e88e"
|
|
543
548
|
},
|
|
549
|
+
{
|
|
550
|
+
"source": "src/project_forge_deployment/topology_cli.py",
|
|
551
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/topology_cli.py",
|
|
552
|
+
"sha256": "83523efa8c175dfc0235844fcfe3b51a4af3e807e83165cca63c81f675030f8c"
|
|
553
|
+
},
|
|
544
554
|
{
|
|
545
555
|
"source": "src/project_forge_deployment/trust.py",
|
|
546
556
|
"destination": "tools/project-forge-deployment/project_forge_deployment/trust.py",
|
|
@@ -561,6 +571,11 @@
|
|
|
561
571
|
"destination": "docs/project-forge/ENVIRONMENT_PROOF.md",
|
|
562
572
|
"sha256": "2ce7225ad6166a4ee30f51ea67ec077de0c29ce3a5c39b4ff57bf11cbe25e927"
|
|
563
573
|
},
|
|
574
|
+
{
|
|
575
|
+
"source": "docs/PROJECT_TOPOLOGY_BINDING.md",
|
|
576
|
+
"destination": "docs/project-forge/PROJECT_TOPOLOGY_BINDING.md",
|
|
577
|
+
"sha256": "50988ac1d797494bcb2d5dd6cece04f6313fb30179ca3132191dea75bcd759cd"
|
|
578
|
+
},
|
|
564
579
|
{
|
|
565
580
|
"source": "docs/images/developer-environment-journey.svg",
|
|
566
581
|
"destination": "docs/project-forge/images/developer-environment-journey.svg",
|
|
@@ -228,6 +228,26 @@ Normal commands then use that exact name, for example:
|
|
|
228
228
|
|
|
229
229
|
Do not create an environment whose required placement/adapter is unsupported; report the concrete `CATALOG GAP` or deferred state instead.
|
|
230
230
|
|
|
231
|
+
### Complete a deferred remote target binding before remote setup
|
|
232
|
+
|
|
233
|
+
When the selected existing environment already declares `placement: remote-target` and `adapter: remote-ssh` but has `bindingState: deferred`, treat target selection as a guided Project-configuration subflow rather than a terminal setup blocker.
|
|
234
|
+
|
|
235
|
+
1. Inspect/sync the developer's reusable non-secret target definitions through installed Deployment Kit personal-profile operations. Prefer `project-forge-deploy environment profile sync --apply --json` when available. Use the returned structured target/import evidence plus current local target profiles; do not invent a workstation registry.
|
|
236
|
+
2. Present compatible existing logical targets as a host-native **single-select** choice. Include `Other / define another target` and `Cancel / keep deferred`. Never silently choose a target, and never preselect or infer a Production target merely from an environment name.
|
|
237
|
+
3. If the developer chooses another target, use the supported Deployment Kit target-profile flow to define that non-secret logical target. Keep SSH identity/trust onboarding separate and never ask for private keys, passwords, or tokens in chat.
|
|
238
|
+
4. Before any Project write, preview the exact binding with the deterministic topology command when available:
|
|
239
|
+
|
|
240
|
+
```text
|
|
241
|
+
project-forge-deploy topology plan-binding <environment> --target-profile <target> --root . --json
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Show the reviewed before/after environment record, selected target, Production classification, changed fields, and required authorization. The supported deferred-binding operation may change only `bindingState` and `targetProfile`; it must not rewrite placement, isolation, exposure, adapter, failure domain, purpose, or role.
|
|
245
|
+
5. Require the bounded Project-configuration authorization reported by the plan. Production-class configuration uses its distinct Production topology-write authorization, but that authorization is **not** a Production proof or application-deployment authorization.
|
|
246
|
+
6. After approval, apply the reviewed plan through the deterministic command (for example `project-forge-deploy topology bind ... --yes --json` when the orchestration layer has just collected the exact required approval). Do not hand-edit `DEPLOYMENT_TOPOLOGY.json` in the skill. If installed Project Forge tooling does not expose the deterministic binding capability, route to the applicable Project Forge update/capability path instead of inventing JSON mutation logic.
|
|
247
|
+
7. Re-read `DEPLOYMENT_TOPOLOGY.json` after the write. Continue normal `/setup <environment>` using the persisted logical target and, when available, use structured `environment setup check`/`plan` evidence for access/platform/proof readiness.
|
|
248
|
+
|
|
249
|
+
A configured environment that already points at a different target is a **rebind**, not deferred binding completion. Stop for a separate explicit topology-change decision; do not reuse this subflow to move an existing environment silently. Bootstrap remains provenance and must not be rewritten to record the later binding.
|
|
250
|
+
|
|
231
251
|
## 7. `/setup remote` and `/setup <environment>` prepare remote-enabled capability
|
|
232
252
|
|
|
233
253
|
Remote setup proves that this workstation can later work with the configured non-Production or Production-class environment under its normal safety contract. It does **not** publish an artifact or deploy the real application.
|
|
@@ -373,4 +393,4 @@ Useful next actions include:
|
|
|
373
393
|
- `/deploy <environment>` — only after readiness and its own authorization contract;
|
|
374
394
|
- `/tear-down <scope>` — destructive cleanup under its separate gate.
|
|
375
395
|
|
|
376
|
-
Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
|
|
396
|
+
Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
|
|
@@ -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(
|
|
@@ -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())
|