@mriekki/project-forge 4.9.0 → 4.10.1
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 +1 -0
- package/assets/generated/ASSEMBLY.json +15 -10
- package/assets/generated/core/core/skills/tear-down/SKILL.md +76 -18
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py +1 -0
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_route_apply.py +165 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +75 -11
- package/assets/generated/deployment/src/project_forge_deployment/ingress_cli.py +1 -1
- package/assets/generated/deployment/src/project_forge_deployment/teardown.py +253 -5
- package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +79 -3
- package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +58 -1
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.10.1",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
|
-
"revision": "
|
|
16
|
+
"revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
|
|
17
17
|
"version": "4.0.0",
|
|
18
18
|
"distribution": "greenfield-workspace",
|
|
19
19
|
"descriptor": "assets/core/distribution.json",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"canonicalDescriptor": "distributions/greenfield-workspace.json"
|
|
22
22
|
},
|
|
23
23
|
"deployment": {
|
|
24
|
-
"revision": "
|
|
24
|
+
"revision": "6b13a6e66218c067771311db1336c73229d23899",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
{"source": "src/project_forge_deployment/cloudflare_api.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_api.py"},
|
|
20
20
|
{"source": "src/project_forge_deployment/cloudflare_ingress.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py"},
|
|
21
21
|
{"source": "src/project_forge_deployment/cloudflare_ingress_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py"},
|
|
22
|
+
{"source": "src/project_forge_deployment/cloudflare_route_apply.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_route_apply.py"},
|
|
22
23
|
{"source": "src/project_forge_deployment/cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/cli.py"},
|
|
23
24
|
{"source": "src/project_forge_deployment/doctor.py", "destination": "tools/project-forge-deployment/project_forge_deployment/doctor.py"},
|
|
24
25
|
{"source": "src/project_forge_deployment/entrypoint.py", "destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py"},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.10.1",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
|
-
"revision": "
|
|
7
|
+
"revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
|
|
8
8
|
"version": "4.0.0",
|
|
9
9
|
"distribution": "greenfield-workspace",
|
|
10
10
|
"files": [
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
{
|
|
127
127
|
"source": "core/skills/tear-down/SKILL.md",
|
|
128
128
|
"destination": ".github/skills/tear-down/SKILL.md",
|
|
129
|
-
"sha256": "
|
|
129
|
+
"sha256": "468314030c6033885c46aba50f54410928cbe44c74d73b998b88338fe495f3d9"
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
"source": "core/skills/coach/SKILL.md",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
]
|
|
327
327
|
},
|
|
328
328
|
"deployment": {
|
|
329
|
-
"revision": "
|
|
329
|
+
"revision": "6b13a6e66218c067771311db1336c73229d23899",
|
|
330
330
|
"distribution": "greenfield-project",
|
|
331
331
|
"files": [
|
|
332
332
|
{
|
|
@@ -407,7 +407,12 @@
|
|
|
407
407
|
{
|
|
408
408
|
"source": "src/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
409
409
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
410
|
-
"sha256": "
|
|
410
|
+
"sha256": "35ac39b4f4538d4ff23f68982cc4b176782378cf03539fa51a228becbdfcf4d0"
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"source": "src/project_forge_deployment/cloudflare_route_apply.py",
|
|
414
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_route_apply.py",
|
|
415
|
+
"sha256": "eea7a5b4322e0601db36d28cb0529522d6acc37c55dfc9a7b41d91518fe14cd4"
|
|
411
416
|
},
|
|
412
417
|
{
|
|
413
418
|
"source": "src/project_forge_deployment/cli.py",
|
|
@@ -422,7 +427,7 @@
|
|
|
422
427
|
{
|
|
423
428
|
"source": "src/project_forge_deployment/entrypoint.py",
|
|
424
429
|
"destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py",
|
|
425
|
-
"sha256": "
|
|
430
|
+
"sha256": "c9dd332a68e923ef15a9f228e0666306c9692dd3ffe724b73160ce313f627ab6"
|
|
426
431
|
},
|
|
427
432
|
{
|
|
428
433
|
"source": "src/project_forge_deployment/environment_cli.py",
|
|
@@ -462,7 +467,7 @@
|
|
|
462
467
|
{
|
|
463
468
|
"source": "src/project_forge_deployment/ingress_cli.py",
|
|
464
469
|
"destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py",
|
|
465
|
-
"sha256": "
|
|
470
|
+
"sha256": "0e4088a17734f560c750528942fbd34d2464bcdd06d60467a9dc684a73fc18e1"
|
|
466
471
|
},
|
|
467
472
|
{
|
|
468
473
|
"source": "src/project_forge_deployment/keys.py",
|
|
@@ -572,17 +577,17 @@
|
|
|
572
577
|
{
|
|
573
578
|
"source": "src/project_forge_deployment/teardown.py",
|
|
574
579
|
"destination": "tools/project-forge-deployment/project_forge_deployment/teardown.py",
|
|
575
|
-
"sha256": "
|
|
580
|
+
"sha256": "0c75bb1ec77218d1b6ee3f54a626556ee46f2de8e91d1e9afa87200bce39254e"
|
|
576
581
|
},
|
|
577
582
|
{
|
|
578
583
|
"source": "src/project_forge_deployment/teardown_cli.py",
|
|
579
584
|
"destination": "tools/project-forge-deployment/project_forge_deployment/teardown_cli.py",
|
|
580
|
-
"sha256": "
|
|
585
|
+
"sha256": "5dbe92b1d5683824bbf3b4892f9099db2d68ad95c0a514bf73f7b596a62775be"
|
|
581
586
|
},
|
|
582
587
|
{
|
|
583
588
|
"source": "src/project_forge_deployment/teardown_remote.py",
|
|
584
589
|
"destination": "tools/project-forge-deployment/project_forge_deployment/teardown_remote.py",
|
|
585
|
-
"sha256": "
|
|
590
|
+
"sha256": "5a898ee8ce981c60839d12c63a776bf742e2c32bc409fbb29cdfd798cf9baa19"
|
|
586
591
|
},
|
|
587
592
|
{
|
|
588
593
|
"source": "src/project_forge_deployment/topology_cli.py",
|
|
@@ -1,27 +1,52 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tear-down
|
|
3
|
-
description: "
|
|
4
|
-
argument-hint: "[local|<environment>|all]"
|
|
3
|
+
description: "List target-owned Project environments and guide destructive cleanup for local state, one exact named environment, or all explicitly configured Project environments. Preserves ownership boundaries, shared Project Forge infrastructure, and separate Production/public-ingress authorization."
|
|
4
|
+
argument-hint: "[list [<target>]|local|<environment>|all]"
|
|
5
5
|
user-invocable: true
|
|
6
6
|
disable-model-invocation: true
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Tear Down
|
|
10
10
|
|
|
11
|
-
`/tear-down` is the human-facing destructive
|
|
11
|
+
`/tear-down` is the human-facing inventory and destructive-cleanup workflow for Project-owned deployment state.
|
|
12
12
|
|
|
13
13
|
Logical environment names are data-driven and are not limited to Staging/Production:
|
|
14
14
|
|
|
15
15
|
```text
|
|
16
|
+
/tear-down list production
|
|
16
17
|
/tear-down demo
|
|
17
18
|
/tear-down qa
|
|
18
19
|
/tear-down staging
|
|
19
20
|
/tear-down prod-east
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
The skill owns exact scope selection, read-only preflight, destructive preview, and Human-in-the-Loop authorization.
|
|
23
|
+
The skill owns target selection, exact scope selection, read-only inventory/preflight, destructive preview, and Human-in-the-Loop authorization. Deployment Kit deterministic tooling owns inventory and deletion mechanics.
|
|
23
24
|
|
|
24
|
-
##
|
|
25
|
+
## 0. List what is installed before destructive selection
|
|
26
|
+
|
|
27
|
+
When the Builder asks what is deployed, what can be removed, or invokes `list`, use the Deployment Kit read-only target inventory:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
project-forge-deploy target inventory <target> --json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This is the canonical application-cleanup inventory. It correlates the target's managed runtime, trusted-LAN route, and managed public-ingress ownership into application/environment tear-down candidates while reporting the shared host platform separately.
|
|
34
|
+
|
|
35
|
+
The command is intentionally independent of the consumer repository. If a consumer checkout or even its GitHub repository has already been deleted, the same installed/standalone Deployment Kit command may still inventory the target from host-owned metadata and user-scoped target access configuration.
|
|
36
|
+
|
|
37
|
+
Inside a consumer repository, use the repository-provided Deployment Kit launcher when that is the installed contract. Detect the current platform/runtime and invoke the portable Python entrypoint or installed `project-forge-deploy`; do not assume PowerShell syntax or Windows-only paths. macOS/Linux shells and Windows/PowerShell must reach the same deterministic CLI semantics.
|
|
38
|
+
|
|
39
|
+
Do not reconstruct inventory by ad-hoc `ssh`, `docker ps`, filesystem listing, or Cloudflare commands when Deployment Kit target inventory is available.
|
|
40
|
+
|
|
41
|
+
If a public-ingress scope cannot be inferred from the application/environment name, pass only the exact additional scope established by repository topology or trusted operator evidence:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
--ingress-scope <scope>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Listing is read-only. It does not authorize later deletion.
|
|
48
|
+
|
|
49
|
+
## 1. Resolve exact project topology when the repository exists
|
|
25
50
|
|
|
26
51
|
Prefer:
|
|
27
52
|
|
|
@@ -37,8 +62,12 @@ If `demo` is configured, `/tear-down demo` means exactly `demo`. If `prod-east`
|
|
|
37
62
|
|
|
38
63
|
Never guess a similar environment and never widen scope after failure.
|
|
39
64
|
|
|
65
|
+
When the repository no longer exists, use target inventory only for target-owned application identity and resource ownership. Do not invent repository-only topology facts. In particular, if a non-literal custom environment's Production classification cannot be established from trusted evidence, stop before destructive authorization rather than weakening the Production-data gate.
|
|
66
|
+
|
|
40
67
|
## 2. Select exact destructive scope
|
|
41
68
|
|
|
69
|
+
The destructive scope contract remains exactly `[local|<environment>|all]`; `list` is a separate read-only discovery mode and never becomes a destructive scope.
|
|
70
|
+
|
|
42
71
|
```text
|
|
43
72
|
local
|
|
44
73
|
<one exact repository-defined named environment>
|
|
@@ -47,13 +76,15 @@ all
|
|
|
47
76
|
|
|
48
77
|
If no scope is named, present only scopes the repository actually supports; never silently default to a Production environment or `all`.
|
|
49
78
|
|
|
79
|
+
For orphaned deployments where the consumer repository is unavailable, present only exact application/environment candidates returned by target inventory.
|
|
80
|
+
|
|
50
81
|
`all` means only all explicitly configured scopes for this Project; it never means machine-wide cleanup.
|
|
51
82
|
|
|
52
83
|
Selecting scope does not authorize deletion. Physical colocation never merges ownership: deleting `demo` must not remove `staging` or `prod-east` merely because they share hardware.
|
|
53
84
|
|
|
54
|
-
If the requested name is absent from project topology, stop. Do not substitute another environment.
|
|
85
|
+
If the requested name is absent from project topology or target inventory, stop. Do not substitute another environment.
|
|
55
86
|
|
|
56
|
-
## 3. Resolve Production classification from the deletion set
|
|
87
|
+
## 3. Resolve Production and public-ingress classification from the deletion set
|
|
57
88
|
|
|
58
89
|
For each selected environment identify:
|
|
59
90
|
|
|
@@ -62,6 +93,8 @@ environment name
|
|
|
62
93
|
purpose / Production classification
|
|
63
94
|
placement / target
|
|
64
95
|
repository/application identity
|
|
96
|
+
trusted-LAN route
|
|
97
|
+
managed public-ingress scope/hostname, when present
|
|
65
98
|
ownership markers required by installed tooling
|
|
66
99
|
```
|
|
67
100
|
|
|
@@ -71,7 +104,13 @@ For current Deployment Kit consumers, custom Production names may be passed expl
|
|
|
71
104
|
--production-environment prod-east
|
|
72
105
|
```
|
|
73
106
|
|
|
74
|
-
|
|
107
|
+
When target inventory reports an owned public route, preserve its exact environment-to-ingress-scope binding for cleanup, conceptually:
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
--public-ingress-scope production=production
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Use actual installed help/version evidence. If the installed tool cannot preserve a custom Production classification or exact public-ingress ownership, stop rather than downgrading the destructive gate.
|
|
75
114
|
|
|
76
115
|
## 4. Present an exact destructive preview
|
|
77
116
|
|
|
@@ -86,10 +125,17 @@ Selected environments:
|
|
|
86
125
|
<name> → <placement/target> — <purpose/classification>
|
|
87
126
|
|
|
88
127
|
Will remove:
|
|
89
|
-
|
|
128
|
+
owned public hostname/DNS + tunnel rule, when selected
|
|
129
|
+
owned trusted-LAN route
|
|
130
|
+
owned application container/runtime
|
|
131
|
+
owned releases, evidence, persistent data, and backups
|
|
132
|
+
remote immutable image only when no other managed release/container still references it
|
|
90
133
|
|
|
91
134
|
Will preserve:
|
|
92
|
-
|
|
135
|
+
shared Project Forge Caddy/network/platform
|
|
136
|
+
shared Cloudflare tunnel/runtime
|
|
137
|
+
unrelated Projects/environments
|
|
138
|
+
remote images still referenced by another managed release/container
|
|
93
139
|
```
|
|
94
140
|
|
|
95
141
|
Do not promise deletion of named/shared Docker volumes unless the installed deterministic contract proves exact ownership and explicitly includes them. Do not promise deletion of any shared/unrelated resource whose ownership cannot be proven.
|
|
@@ -108,33 +154,45 @@ whenever **any selected environment is Production-class**, require the stronger
|
|
|
108
154
|
[PRODUCTION DATA DELETE]
|
|
109
155
|
```
|
|
110
156
|
|
|
157
|
+
Whenever the reviewed deletion set includes an owned public-ingress route, also require the separate public-ingress removal authorization, conceptually:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
[PUBLIC INGRESS ROUTE REMOVE]
|
|
161
|
+
```
|
|
162
|
+
|
|
111
163
|
Resolve exact phrases from the installed tool/version. Do not tie the Production-data gate only to literal scope strings such as `production` or `all`; it depends on the actual resolved deletion set.
|
|
112
164
|
|
|
113
|
-
A prior `/setup`, environment proof, `/deploy`, merge, CI, or other approval never authorizes destructive cleanup.
|
|
165
|
+
A prior `/setup`, environment proof, `/deploy`, merge, CI, public-route registration, or other approval never authorizes destructive cleanup.
|
|
114
166
|
|
|
115
|
-
If identity, selected environment set, classification, binding, ownership evidence, or deletion set changes after preview, require renewed review/authorization.
|
|
167
|
+
If identity, selected environment set, classification, binding, ownership evidence, public-ingress scope, or deletion set changes after preview, require renewed review/authorization.
|
|
116
168
|
|
|
117
|
-
## 6. Execute only
|
|
169
|
+
## 6. Execute only Deployment Kit deterministic cleanup
|
|
118
170
|
|
|
119
171
|
After authorization, invoke only the approved tear-down command with exact named scopes/bindings. Do not improvise shell commands.
|
|
120
172
|
|
|
121
173
|
For a generalized current Deployment Kit invocation, exact bindings may look conceptually like:
|
|
122
174
|
|
|
123
175
|
```text
|
|
124
|
-
--environment-target demo=<target>
|
|
125
176
|
--environment-target staging=<target>
|
|
126
|
-
--environment-target
|
|
177
|
+
--environment-target production=<target>
|
|
178
|
+
--public-ingress-scope production=production
|
|
127
179
|
--production-environment prod-east
|
|
128
180
|
```
|
|
129
181
|
|
|
130
|
-
Use the installed contract rather than inventing flags for older versions.
|
|
182
|
+
Use target inventory output and the installed contract rather than inventing flags for older versions.
|
|
131
183
|
|
|
132
|
-
|
|
184
|
+
Public ingress must be removed before the trusted-LAN route/runtime so a public hostname is never deliberately left pointing at a removed origin. The shared Cloudflare tunnel/runtime is host infrastructure and must remain.
|
|
185
|
+
|
|
186
|
+
Remote image cleanup is bounded: remove only the selected environment's immutable image when no other managed release metadata or container still references it. Never use Docker system/image/volume prune.
|
|
187
|
+
|
|
188
|
+
Never add machine-wide Docker prune, shared platform/network removal, unrelated environment cleanup, host reprovisioning, target rebinding, credential/trust deletion, registry-package deletion, or shared Cloudflare tunnel deletion unless a separate lifecycle operation explicitly owns and authorizes it.
|
|
133
189
|
|
|
134
190
|
If aggregate `all` cleanup stops partway, report completed and remaining scopes. Do not manually compensate by deleting the rest.
|
|
135
191
|
|
|
136
192
|
## 7. Verify and report outcome
|
|
137
193
|
|
|
138
|
-
|
|
194
|
+
Re-run target inventory after cleanup when available. Report only observed evidence: requested scope, exact environment names/targets processed, Production-class environments included, public routes removed/already absent, trusted-LAN routes removed/already absent, runtime/data removal, remote image removed/retained, local state processed, and any remaining scopes after a partial stop.
|
|
195
|
+
|
|
196
|
+
Shared Project Forge platform state and unrelated application candidates should remain present and unchanged.
|
|
139
197
|
|
|
140
198
|
When the installed deterministic contract is idempotent, already-absent owned resources should remain safe to re-check/re-run without broadening scope.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Safe public-route apply wrapper for Cloudflare provider normalization.
|
|
2
|
+
|
|
3
|
+
Cloudflare currently adds ``warp-routing: {enabled: false}`` when returning a
|
|
4
|
+
remotely managed tunnel configuration even when that provider default was not
|
|
5
|
+
submitted. Project Forge tolerates only that observed no-op normalization;
|
|
6
|
+
all reviewed ingress rules and every other configuration value remain exact.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import cloudflare_api
|
|
15
|
+
from . import cloudflare_ingress as ingress
|
|
16
|
+
from .errors import DeploymentKitError
|
|
17
|
+
from .profiles import load_profile
|
|
18
|
+
from .ssh import verify_access
|
|
19
|
+
|
|
20
|
+
_FINAL_VERIFICATION_ERRORS = {
|
|
21
|
+
"Public route registration returned without exact host ownership metadata.",
|
|
22
|
+
"Cloudflare tunnel configuration does not match the reviewed route plan after apply.",
|
|
23
|
+
"Cloudflare DNS record is missing after public-route apply.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _reviewed_config_matches(actual: Any, reviewed: Any) -> bool:
|
|
28
|
+
"""Match reviewed config after removing only the observed safe provider default."""
|
|
29
|
+
if not isinstance(actual, dict) or not isinstance(reviewed, dict):
|
|
30
|
+
return False
|
|
31
|
+
normalized = deepcopy(actual)
|
|
32
|
+
if "warp-routing" not in reviewed and normalized.get("warp-routing") == {"enabled": False}:
|
|
33
|
+
normalized.pop("warp-routing")
|
|
34
|
+
return normalized == reviewed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_final_state(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
38
|
+
name = plan["targetName"]
|
|
39
|
+
scope = plan["scope"]
|
|
40
|
+
spec = plan["route"]
|
|
41
|
+
host = ingress.cloudflare_ingress_status(name, scope)
|
|
42
|
+
inventory = ingress.cloudflare_route_inventory(name, scope)
|
|
43
|
+
route = ingress._find_route(inventory, spec["routeId"])
|
|
44
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
|
|
45
|
+
records = cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"])
|
|
46
|
+
dns_error: str | None = None
|
|
47
|
+
try:
|
|
48
|
+
dns = ingress._exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
|
|
49
|
+
except DeploymentKitError as error:
|
|
50
|
+
dns = None
|
|
51
|
+
dns_error = str(error)
|
|
52
|
+
return {
|
|
53
|
+
"host": host,
|
|
54
|
+
"route": route,
|
|
55
|
+
"config": config,
|
|
56
|
+
"dns": dns,
|
|
57
|
+
"dnsError": dns_error,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _rollback_final_state(
|
|
62
|
+
plan: dict[str, Any],
|
|
63
|
+
state: dict[str, Any],
|
|
64
|
+
*,
|
|
65
|
+
api_token: str,
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
"""Best-effort reverse only mutations that can still be attributed safely."""
|
|
68
|
+
errors: list[str] = []
|
|
69
|
+
host = state["host"]
|
|
70
|
+
spec = plan["route"]
|
|
71
|
+
|
|
72
|
+
if plan["dnsRecord"] is None:
|
|
73
|
+
dns = state["dns"]
|
|
74
|
+
if dns is not None:
|
|
75
|
+
try:
|
|
76
|
+
cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
|
|
77
|
+
except Exception as error: # pragma: no cover - provider failure detail only
|
|
78
|
+
errors.append(f"DNS rollback failed: {error}")
|
|
79
|
+
elif state["dnsError"] is not None:
|
|
80
|
+
errors.append("DNS rollback skipped because current DNS ownership is ambiguous.")
|
|
81
|
+
|
|
82
|
+
if plan["desiredTunnelConfig"] != plan["tunnelConfig"]:
|
|
83
|
+
current = state["config"]
|
|
84
|
+
if _reviewed_config_matches(current, plan["desiredTunnelConfig"]):
|
|
85
|
+
try:
|
|
86
|
+
cloudflare_api.update_tunnel_configuration(
|
|
87
|
+
api_token,
|
|
88
|
+
host["accountId"],
|
|
89
|
+
host["tunnelId"],
|
|
90
|
+
plan["tunnelConfig"],
|
|
91
|
+
)
|
|
92
|
+
except Exception as error: # pragma: no cover - provider failure detail only
|
|
93
|
+
errors.append(f"tunnel configuration rollback failed: {error}")
|
|
94
|
+
elif not _reviewed_config_matches(current, plan["tunnelConfig"]):
|
|
95
|
+
errors.append(
|
|
96
|
+
"Tunnel configuration rollback skipped because current provider state cannot be safely attributed to this apply."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if plan["previousRoute"] is None and state["route"] == spec:
|
|
100
|
+
try:
|
|
101
|
+
profile = load_profile(plan["targetName"])
|
|
102
|
+
access = verify_access(profile)
|
|
103
|
+
ingress._run_remote(
|
|
104
|
+
profile,
|
|
105
|
+
access["address"],
|
|
106
|
+
"route-remove",
|
|
107
|
+
scope=plan["scope"],
|
|
108
|
+
spec=spec,
|
|
109
|
+
)
|
|
110
|
+
except Exception as error: # pragma: no cover - remote failure detail only
|
|
111
|
+
errors.append(f"host route rollback failed: {error}")
|
|
112
|
+
|
|
113
|
+
return errors
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
|
|
117
|
+
"""Apply a reviewed public route with bounded post-write recovery.
|
|
118
|
+
|
|
119
|
+
The underlying apply retains all pre-write stale-state checks and its write
|
|
120
|
+
rollback. Recovery is entered only for errors that are emitted exclusively
|
|
121
|
+
by the final verification phase, so preflight failures can never be turned
|
|
122
|
+
into success by observing later provider state.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
return ingress.apply_public_route(plan, api_token=api_token)
|
|
126
|
+
except Exception as original_error:
|
|
127
|
+
if (
|
|
128
|
+
not isinstance(original_error, DeploymentKitError)
|
|
129
|
+
or str(original_error) not in _FINAL_VERIFICATION_ERRORS
|
|
130
|
+
or not isinstance(plan, dict)
|
|
131
|
+
or plan.get("action") != "register"
|
|
132
|
+
):
|
|
133
|
+
raise
|
|
134
|
+
required = {
|
|
135
|
+
"targetName", "scope", "route", "previousRoute", "tunnelConfig",
|
|
136
|
+
"desiredTunnelConfig", "dnsRecord", "dnsMarker",
|
|
137
|
+
}
|
|
138
|
+
if not required.issubset(plan):
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
state = _read_final_state(plan, api_token=api_token)
|
|
143
|
+
except Exception:
|
|
144
|
+
raise original_error
|
|
145
|
+
|
|
146
|
+
route_ok = state["route"] == plan["route"]
|
|
147
|
+
config_ok = _reviewed_config_matches(state["config"], plan["desiredTunnelConfig"])
|
|
148
|
+
dns_ok = state["dns"] is not None
|
|
149
|
+
if route_ok and config_ok and dns_ok:
|
|
150
|
+
return {
|
|
151
|
+
"targetName": plan["targetName"],
|
|
152
|
+
"scope": plan["scope"],
|
|
153
|
+
"route": plan["route"],
|
|
154
|
+
"dnsRecordId": str(state["dns"]["id"]),
|
|
155
|
+
"publicUrl": f"https://{plan['route']['publicHostname']}",
|
|
156
|
+
"mutated": True,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
rollback_errors = _rollback_final_state(plan, state, api_token=api_token)
|
|
160
|
+
if rollback_errors:
|
|
161
|
+
raise DeploymentKitError(
|
|
162
|
+
"Cloudflare public-route final verification failed and rollback was incomplete: "
|
|
163
|
+
+ "; ".join(rollback_errors)
|
|
164
|
+
) from original_error
|
|
165
|
+
raise original_error
|
|
@@ -1,23 +1,84 @@
|
|
|
1
|
-
"""Top-level console entrypoint for Deployment Kit command groups.
|
|
1
|
+
"""Top-level console entrypoint for Deployment Kit command groups.
|
|
2
|
+
|
|
3
|
+
Command groups are loaded lazily so an optional capability defect cannot prevent
|
|
4
|
+
unrelated Deployment Kit commands from starting.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
|
|
5
9
|
import sys
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
from .
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from .
|
|
16
|
-
|
|
11
|
+
|
|
12
|
+
def access_main(argv: list[str]) -> int:
|
|
13
|
+
from .access_cli import main
|
|
14
|
+
|
|
15
|
+
return main(argv)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def app_main(argv: list[str]) -> int:
|
|
19
|
+
from .app_cli import main
|
|
20
|
+
|
|
21
|
+
return main(argv)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def core_main(argv: list[str]) -> int:
|
|
25
|
+
from .cli import main
|
|
26
|
+
|
|
27
|
+
return main(argv)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def environment_main(argv: list[str]) -> int:
|
|
31
|
+
from .environment_cli import main
|
|
32
|
+
|
|
33
|
+
return main(argv)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ingress_main(argv: list[str]) -> int:
|
|
37
|
+
from .ingress_cli import main
|
|
38
|
+
|
|
39
|
+
return main(argv)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def local_main(argv: list[str]) -> int:
|
|
43
|
+
from .local_cli import main
|
|
44
|
+
|
|
45
|
+
return main(argv)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def registry_main(argv: list[str]) -> int:
|
|
49
|
+
from .registry_cli import main
|
|
50
|
+
|
|
51
|
+
return main(argv)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sqlite_main(argv: list[str]) -> int:
|
|
55
|
+
from .sqlite_cli import main
|
|
56
|
+
|
|
57
|
+
return main(argv)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def target_inventory_main(argv: list[str]) -> int:
|
|
61
|
+
from .teardown_cli import inventory_main
|
|
62
|
+
|
|
63
|
+
return inventory_main(argv)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def teardown_main(argv: list[str]) -> int:
|
|
67
|
+
from .teardown_cli import main
|
|
68
|
+
|
|
69
|
+
return main(argv)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def topology_main(argv: list[str]) -> int:
|
|
73
|
+
from .topology_cli import main
|
|
74
|
+
|
|
75
|
+
return main(argv)
|
|
17
76
|
|
|
18
77
|
|
|
19
78
|
def main(argv: list[str] | None = None) -> int:
|
|
20
79
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
80
|
+
if len(args) >= 2 and args[0] == "target" and args[1] == "inventory":
|
|
81
|
+
return target_inventory_main(args[2:])
|
|
21
82
|
if args and args[0] == "access":
|
|
22
83
|
return access_main(args[1:])
|
|
23
84
|
if args and args[0] == "app":
|
|
@@ -36,6 +97,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
36
97
|
return teardown_main(args[1:])
|
|
37
98
|
if args and args[0] == "topology":
|
|
38
99
|
return topology_main(args[1:])
|
|
100
|
+
if args[:2] in (["target", "--help"], ["target", "-h"]):
|
|
101
|
+
print("Additional command: inventory Read app-owned tear-down candidates without writes")
|
|
39
102
|
if not args or args == ["--help"] or args == ["-h"]:
|
|
40
103
|
print("Command group: access Read-only strict SSH target-access diagnosis")
|
|
41
104
|
print("Command group: app Remote immutable application runtime inventory/deployment")
|
|
@@ -46,4 +109,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
46
109
|
print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
|
|
47
110
|
print("Command group: tear-down Scoped destructive Project environment/local cleanup")
|
|
48
111
|
print("Command group: topology Project-owned logical environment binding")
|
|
112
|
+
print("Target command: inventory Read app-owned tear-down candidates without writes")
|
|
49
113
|
return core_main(args)
|
|
@@ -14,7 +14,6 @@ from .cloudflare_ingress import (
|
|
|
14
14
|
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
15
15
|
PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
|
|
16
16
|
apply_cloudflare_ingress,
|
|
17
|
-
apply_public_route,
|
|
18
17
|
apply_public_route_removal,
|
|
19
18
|
cloudflare_ingress_doctor,
|
|
20
19
|
cloudflare_ingress_status,
|
|
@@ -23,6 +22,7 @@ from .cloudflare_ingress import (
|
|
|
23
22
|
plan_public_route,
|
|
24
23
|
plan_public_route_removal,
|
|
25
24
|
)
|
|
25
|
+
from .cloudflare_route_apply import apply_public_route
|
|
26
26
|
from .errors import DeploymentKitError
|
|
27
27
|
|
|
28
28
|
|
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
import shlex
|
|
8
9
|
import shutil
|
|
9
10
|
import subprocess
|
|
10
11
|
from typing import Any, Iterable
|
|
11
12
|
|
|
13
|
+
from .app_runtime import app_inventory
|
|
14
|
+
from .box_platform import platform_status
|
|
15
|
+
from .cloudflare_ingress import (
|
|
16
|
+
CLOUDFLARE_API_TOKEN_ENV,
|
|
17
|
+
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
18
|
+
apply_public_route_removal,
|
|
19
|
+
cloudflare_ingress_status,
|
|
20
|
+
cloudflare_route_inventory,
|
|
21
|
+
plan_public_route_removal,
|
|
22
|
+
)
|
|
12
23
|
from .errors import DeploymentKitError
|
|
13
24
|
from .profiles import load_profile
|
|
14
25
|
from .route_registry import apply_route_change, plan_route_removal, route_inventory
|
|
@@ -60,6 +71,24 @@ def parse_environment_targets(items: Iterable[str]) -> dict[str, str]:
|
|
|
60
71
|
return dict(sorted(result.items()))
|
|
61
72
|
|
|
62
73
|
|
|
74
|
+
def parse_environment_scopes(items: Iterable[str]) -> dict[str, str]:
|
|
75
|
+
result: dict[str, str] = {}
|
|
76
|
+
for item in items:
|
|
77
|
+
if not isinstance(item, str) or "=" not in item:
|
|
78
|
+
raise DeploymentKitError("public-ingress scope bindings must use environment=scope form.")
|
|
79
|
+
environment, scope = item.split("=", 1)
|
|
80
|
+
environment = _slug(environment, "environment")
|
|
81
|
+
if environment in RESERVED_SCOPES:
|
|
82
|
+
raise DeploymentKitError(f"{environment!r} is reserved and cannot be a remote environment name.")
|
|
83
|
+
scope = _slug(scope, "public ingress scope")
|
|
84
|
+
if scope in RESERVED_SCOPES:
|
|
85
|
+
raise DeploymentKitError(f"{scope!r} is reserved and cannot be a public ingress scope.")
|
|
86
|
+
if environment in result:
|
|
87
|
+
raise DeploymentKitError(f"Duplicate public-ingress scope binding: {environment}")
|
|
88
|
+
result[environment] = scope
|
|
89
|
+
return dict(sorted(result.items()))
|
|
90
|
+
|
|
91
|
+
|
|
63
92
|
def _normalize_production_environments(items: Iterable[str]) -> set[str]:
|
|
64
93
|
result = {"production"}
|
|
65
94
|
for item in items:
|
|
@@ -78,6 +107,7 @@ def normalize_teardown_spec(
|
|
|
78
107
|
environment_targets: dict[str, str] | Iterable[tuple[str, str]],
|
|
79
108
|
local_package: str | None,
|
|
80
109
|
production_environments: Iterable[str] = (),
|
|
110
|
+
public_ingress_scopes: dict[str, str] | Iterable[tuple[str, str]] = (),
|
|
81
111
|
) -> dict[str, Any]:
|
|
82
112
|
scope = _slug(scope, "scope")
|
|
83
113
|
pairs = environment_targets.items() if isinstance(environment_targets, dict) else environment_targets
|
|
@@ -92,6 +122,23 @@ def normalize_teardown_spec(
|
|
|
92
122
|
raise DeploymentKitError(f"Duplicate environment target binding: {environment}")
|
|
93
123
|
normalized_targets[environment] = target
|
|
94
124
|
|
|
125
|
+
ingress_pairs = (
|
|
126
|
+
public_ingress_scopes.items()
|
|
127
|
+
if isinstance(public_ingress_scopes, dict)
|
|
128
|
+
else public_ingress_scopes
|
|
129
|
+
)
|
|
130
|
+
normalized_ingress: dict[str, str] = {}
|
|
131
|
+
for environment, ingress_scope in ingress_pairs:
|
|
132
|
+
environment = _slug(environment, "environment")
|
|
133
|
+
ingress_scope = _slug(ingress_scope, "public ingress scope")
|
|
134
|
+
if environment not in normalized_targets:
|
|
135
|
+
raise DeploymentKitError(
|
|
136
|
+
f"public ingress scope binding {environment!r} has no matching environment target binding."
|
|
137
|
+
)
|
|
138
|
+
if environment in normalized_ingress:
|
|
139
|
+
raise DeploymentKitError(f"Duplicate public-ingress scope binding: {environment}")
|
|
140
|
+
normalized_ingress[environment] = ingress_scope
|
|
141
|
+
|
|
95
142
|
if scope == "local":
|
|
96
143
|
selected_targets: dict[str, str] = {}
|
|
97
144
|
elif scope == "all":
|
|
@@ -107,15 +154,21 @@ def normalize_teardown_spec(
|
|
|
107
154
|
|
|
108
155
|
production_set = _normalize_production_environments(production_environments)
|
|
109
156
|
selected_production = sorted(set(selected_targets).intersection(production_set))
|
|
157
|
+
selected_ingress = {
|
|
158
|
+
environment: normalized_ingress[environment]
|
|
159
|
+
for environment in selected_targets
|
|
160
|
+
if environment in normalized_ingress
|
|
161
|
+
}
|
|
110
162
|
|
|
111
163
|
return {
|
|
112
|
-
"schemaVersion":
|
|
164
|
+
"schemaVersion": 3,
|
|
113
165
|
"ownerRepo": _owner_repo(owner_repo),
|
|
114
166
|
"appSlug": _slug(app_slug, "appSlug"),
|
|
115
167
|
"scope": scope,
|
|
116
168
|
"environmentTargets": selected_targets,
|
|
117
169
|
"localPackage": package,
|
|
118
170
|
"productionEnvironments": selected_production,
|
|
171
|
+
"publicIngressScopes": selected_ingress,
|
|
119
172
|
}
|
|
120
173
|
|
|
121
174
|
|
|
@@ -124,6 +177,178 @@ def _find_route(inventory: dict[str, Any], app_slug: str, environment: str) -> d
|
|
|
124
177
|
return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
|
|
125
178
|
|
|
126
179
|
|
|
180
|
+
def _ensure_candidate(
|
|
181
|
+
candidates: dict[str, dict[str, Any]],
|
|
182
|
+
*,
|
|
183
|
+
owner_repo: str,
|
|
184
|
+
app_slug: str,
|
|
185
|
+
environment: str,
|
|
186
|
+
target_name: str,
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
route_id = f"{app_slug}--{environment}"
|
|
189
|
+
candidate = candidates.get(route_id)
|
|
190
|
+
if candidate is None:
|
|
191
|
+
candidate = {
|
|
192
|
+
"routeId": route_id,
|
|
193
|
+
"ownerRepo": owner_repo,
|
|
194
|
+
"appSlug": app_slug,
|
|
195
|
+
"environment": environment,
|
|
196
|
+
"targetName": target_name,
|
|
197
|
+
"runtime": None,
|
|
198
|
+
"trustedLanRoute": None,
|
|
199
|
+
"publicIngress": [],
|
|
200
|
+
}
|
|
201
|
+
candidates[route_id] = candidate
|
|
202
|
+
return candidate
|
|
203
|
+
if candidate["ownerRepo"] != owner_repo:
|
|
204
|
+
raise DeploymentKitError(
|
|
205
|
+
f"Target inventory ownership mismatch for {route_id}: "
|
|
206
|
+
f"{candidate['ownerRepo']} versus {owner_repo}."
|
|
207
|
+
)
|
|
208
|
+
return candidate
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def target_inventory(name: str, *, ingress_scopes: Iterable[str] = ()) -> dict[str, Any]:
|
|
212
|
+
"""Read one target's shared platform and application-owned tear-down candidates."""
|
|
213
|
+
platform = platform_status(name)
|
|
214
|
+
apps_result = app_inventory(name)
|
|
215
|
+
lan_result = route_inventory(name)
|
|
216
|
+
|
|
217
|
+
candidates: dict[str, dict[str, Any]] = {}
|
|
218
|
+
inferred_scopes: set[str] = set()
|
|
219
|
+
|
|
220
|
+
for app in apps_result["applications"]:
|
|
221
|
+
candidate = _ensure_candidate(
|
|
222
|
+
candidates,
|
|
223
|
+
owner_repo=app["ownerRepo"],
|
|
224
|
+
app_slug=app["appSlug"],
|
|
225
|
+
environment=app["environment"],
|
|
226
|
+
target_name=name,
|
|
227
|
+
)
|
|
228
|
+
candidate["runtime"] = app
|
|
229
|
+
inferred_scopes.add(app["environment"])
|
|
230
|
+
|
|
231
|
+
for route in lan_result["managedRoutes"]:
|
|
232
|
+
candidate = _ensure_candidate(
|
|
233
|
+
candidates,
|
|
234
|
+
owner_repo=route["ownerRepo"],
|
|
235
|
+
app_slug=route["appSlug"],
|
|
236
|
+
environment=route["environment"],
|
|
237
|
+
target_name=name,
|
|
238
|
+
)
|
|
239
|
+
candidate["trustedLanRoute"] = route
|
|
240
|
+
inferred_scopes.add(route["environment"])
|
|
241
|
+
|
|
242
|
+
if isinstance(name, str) and _SLUG_RE.fullmatch(name):
|
|
243
|
+
inferred_scopes.add(name)
|
|
244
|
+
requested_scopes = {_slug(item, "public ingress scope") for item in ingress_scopes}
|
|
245
|
+
scopes = sorted(inferred_scopes | requested_scopes)
|
|
246
|
+
|
|
247
|
+
public_scopes: list[dict[str, Any]] = []
|
|
248
|
+
for scope in scopes:
|
|
249
|
+
status = cloudflare_ingress_status(name, scope)
|
|
250
|
+
routes: list[dict[str, Any]] = []
|
|
251
|
+
if status["markerPresent"]:
|
|
252
|
+
routes = cloudflare_route_inventory(name, scope)["managedRoutes"]
|
|
253
|
+
for route in routes:
|
|
254
|
+
candidate = _ensure_candidate(
|
|
255
|
+
candidates,
|
|
256
|
+
owner_repo=route["ownerRepo"],
|
|
257
|
+
app_slug=route["appSlug"],
|
|
258
|
+
environment=route["environment"],
|
|
259
|
+
target_name=name,
|
|
260
|
+
)
|
|
261
|
+
candidate["publicIngress"].append({"scope": scope, **route})
|
|
262
|
+
public_scopes.append(
|
|
263
|
+
{
|
|
264
|
+
"scope": scope,
|
|
265
|
+
"state": status["state"],
|
|
266
|
+
"markerPresent": status["markerPresent"],
|
|
267
|
+
"managedRoutes": routes,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
applications: list[dict[str, Any]] = []
|
|
272
|
+
for candidate in sorted(
|
|
273
|
+
candidates.values(),
|
|
274
|
+
key=lambda item: (item["appSlug"], item["environment"], item["ownerRepo"]),
|
|
275
|
+
):
|
|
276
|
+
public_bindings = sorted(
|
|
277
|
+
{item["scope"] for item in candidate["publicIngress"]}
|
|
278
|
+
)
|
|
279
|
+
literal_production = candidate["environment"] == "production"
|
|
280
|
+
candidate["tearDown"] = {
|
|
281
|
+
"available": True,
|
|
282
|
+
"scope": candidate["environment"],
|
|
283
|
+
"environmentTarget": f"{candidate['environment']}={name}",
|
|
284
|
+
"publicIngressScopes": [
|
|
285
|
+
f"{candidate['environment']}={scope}" for scope in public_bindings
|
|
286
|
+
],
|
|
287
|
+
"productionClassification": (
|
|
288
|
+
"production" if literal_production else "requires-trusted-evidence"
|
|
289
|
+
),
|
|
290
|
+
"productionClassificationSource": (
|
|
291
|
+
"literal-environment-name" if literal_production else "not-encoded-in-target-inventory"
|
|
292
|
+
),
|
|
293
|
+
"requiresProductionDataAuthorization": True if literal_production else None,
|
|
294
|
+
"requiresPublicIngressAuthorization": bool(public_bindings),
|
|
295
|
+
}
|
|
296
|
+
applications.append(candidate)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"schemaVersion": 1,
|
|
300
|
+
"targetName": name,
|
|
301
|
+
"verifiedAddress": apps_result.get("verifiedAddress") or lan_result.get("verifiedAddress"),
|
|
302
|
+
"sharedPlatform": platform,
|
|
303
|
+
"applications": applications,
|
|
304
|
+
"publicIngressScopes": public_scopes,
|
|
305
|
+
"mutated": False,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _tear_down_public_ingress(
|
|
310
|
+
target: str,
|
|
311
|
+
scope: str,
|
|
312
|
+
owner_repo: str,
|
|
313
|
+
app_slug: str,
|
|
314
|
+
environment: str,
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
inventory = cloudflare_route_inventory(target, scope)
|
|
317
|
+
route = _find_route(inventory, app_slug, environment)
|
|
318
|
+
if route is None:
|
|
319
|
+
return {"state": "absent", "mutated": False, "scope": scope}
|
|
320
|
+
if (
|
|
321
|
+
route["ownerRepo"] != owner_repo
|
|
322
|
+
or route["appSlug"] != app_slug
|
|
323
|
+
or route["environment"] != environment
|
|
324
|
+
):
|
|
325
|
+
raise DeploymentKitError(
|
|
326
|
+
f"Public route {app_slug}--{environment} ownership does not match the requested Project."
|
|
327
|
+
)
|
|
328
|
+
token = os.environ.get(CLOUDFLARE_API_TOKEN_ENV, "").strip()
|
|
329
|
+
if not token:
|
|
330
|
+
raise DeploymentKitError(
|
|
331
|
+
f"Cloudflare API authorization is required to remove owned public ingress. "
|
|
332
|
+
f"Set {CLOUDFLARE_API_TOKEN_ENV} in the current workstation process/secret boundary; "
|
|
333
|
+
"never put the token in the Project repository or command line."
|
|
334
|
+
)
|
|
335
|
+
plan = plan_public_route_removal(
|
|
336
|
+
target,
|
|
337
|
+
scope=scope,
|
|
338
|
+
owner_repo=owner_repo,
|
|
339
|
+
environment=environment,
|
|
340
|
+
app_slug=app_slug,
|
|
341
|
+
api_token=token,
|
|
342
|
+
)
|
|
343
|
+
result = apply_public_route_removal(plan, api_token=token)
|
|
344
|
+
return {
|
|
345
|
+
"state": "removed",
|
|
346
|
+
"mutated": bool(result.get("mutated")),
|
|
347
|
+
"scope": scope,
|
|
348
|
+
"publicHostname": route["publicHostname"],
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
127
352
|
def _tear_down_route(target: str, owner_repo: str, app_slug: str, environment: str) -> dict[str, Any]:
|
|
128
353
|
inventory = route_inventory(target)
|
|
129
354
|
route = _find_route(inventory, app_slug, environment)
|
|
@@ -170,13 +395,18 @@ def _tear_down_remote_environment(
|
|
|
170
395
|
"Remote Project tear-down failed: " + (stderr or stdout or f"exit {completed.returncode}")
|
|
171
396
|
)
|
|
172
397
|
parts = stdout.split("\t")
|
|
173
|
-
if len(parts) not in {4, 5} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
|
|
398
|
+
if len(parts) not in {4, 5, 7} or parts[0] != "TEAR_DOWN" or parts[1] not in {"absent", "removed"}:
|
|
174
399
|
raise DeploymentKitError("Remote Project tear-down returned malformed evidence.")
|
|
175
400
|
if parts[2] != app_slug or parts[3] != environment:
|
|
176
401
|
raise DeploymentKitError("Remote Project tear-down returned mismatched app/environment evidence.")
|
|
177
402
|
result: dict[str, Any] = {"state": parts[1], "mutated": parts[1] == "removed"}
|
|
178
|
-
if len(parts)
|
|
403
|
+
if len(parts) >= 5:
|
|
179
404
|
result["containerName"] = parts[4]
|
|
405
|
+
if len(parts) == 7:
|
|
406
|
+
if parts[6] not in {"true", "false"}:
|
|
407
|
+
raise DeploymentKitError("Remote Project tear-down returned malformed image-removal evidence.")
|
|
408
|
+
result["images"] = [item for item in parts[5].split(",") if item]
|
|
409
|
+
result["allImagesRemoved"] = parts[6] == "true"
|
|
180
410
|
return result
|
|
181
411
|
|
|
182
412
|
|
|
@@ -256,11 +486,21 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
256
486
|
environment_targets=(spec.get("environmentTargets") or {}).items(),
|
|
257
487
|
local_package=spec.get("localPackage"),
|
|
258
488
|
production_environments=spec.get("productionEnvironments") or (),
|
|
489
|
+
public_ingress_scopes=(spec.get("publicIngressScopes") or {}).items(),
|
|
259
490
|
)
|
|
260
491
|
|
|
261
492
|
environments: list[dict[str, Any]] = []
|
|
262
493
|
mutated = False
|
|
263
494
|
for environment, target in normalized["environmentTargets"].items():
|
|
495
|
+
public_ingress: dict[str, Any] = {"state": "not-selected", "mutated": False}
|
|
496
|
+
if environment in normalized["publicIngressScopes"]:
|
|
497
|
+
public_ingress = _tear_down_public_ingress(
|
|
498
|
+
target,
|
|
499
|
+
normalized["publicIngressScopes"][environment],
|
|
500
|
+
normalized["ownerRepo"],
|
|
501
|
+
normalized["appSlug"],
|
|
502
|
+
environment,
|
|
503
|
+
)
|
|
264
504
|
route = _tear_down_route(target, normalized["ownerRepo"], normalized["appSlug"], environment)
|
|
265
505
|
runtime = _tear_down_remote_environment(
|
|
266
506
|
target,
|
|
@@ -268,11 +508,17 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
268
508
|
normalized["appSlug"],
|
|
269
509
|
environment,
|
|
270
510
|
)
|
|
271
|
-
mutated =
|
|
511
|
+
mutated = (
|
|
512
|
+
mutated
|
|
513
|
+
or public_ingress["mutated"]
|
|
514
|
+
or route["mutated"]
|
|
515
|
+
or runtime["mutated"]
|
|
516
|
+
)
|
|
272
517
|
environments.append(
|
|
273
518
|
{
|
|
274
519
|
"environment": environment,
|
|
275
520
|
"targetName": target,
|
|
521
|
+
"publicIngress": public_ingress,
|
|
276
522
|
"route": route,
|
|
277
523
|
"runtimeAndPersistentData": runtime,
|
|
278
524
|
}
|
|
@@ -284,7 +530,7 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
284
530
|
mutated = mutated or workstation["mutated"]
|
|
285
531
|
|
|
286
532
|
return {
|
|
287
|
-
"schemaVersion":
|
|
533
|
+
"schemaVersion": 3,
|
|
288
534
|
"ownerRepo": normalized["ownerRepo"],
|
|
289
535
|
"appSlug": normalized["appSlug"],
|
|
290
536
|
"scope": normalized["scope"],
|
|
@@ -292,6 +538,8 @@ def tear_down_project(spec: dict[str, Any]) -> dict[str, Any]:
|
|
|
292
538
|
"workstation": workstation,
|
|
293
539
|
"productionEnvironments": normalized["productionEnvironments"],
|
|
294
540
|
"productionDataIncluded": bool(normalized["productionEnvironments"]),
|
|
541
|
+
"publicIngressScopes": normalized["publicIngressScopes"],
|
|
542
|
+
"publicIngressIncluded": bool(normalized["publicIngressScopes"]),
|
|
295
543
|
"mutated": mutated,
|
|
296
544
|
}
|
|
297
545
|
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import json
|
|
6
7
|
import sys
|
|
7
8
|
|
|
8
9
|
from .errors import DeploymentKitError
|
|
9
10
|
from .teardown import (
|
|
10
11
|
PRODUCTION_DATA_DELETE_PHRASE,
|
|
12
|
+
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
11
13
|
TEAR_DOWN_WRITE_PHRASE,
|
|
12
14
|
normalize_teardown_spec,
|
|
15
|
+
parse_environment_scopes,
|
|
13
16
|
parse_environment_targets,
|
|
14
17
|
render_teardown_summary,
|
|
18
|
+
target_inventory,
|
|
15
19
|
tear_down_project,
|
|
16
20
|
)
|
|
17
21
|
|
|
@@ -22,7 +26,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
22
26
|
description=(
|
|
23
27
|
"Remove one named Kit-managed environment, all explicitly bound environments, "
|
|
24
28
|
"and/or exact-package local Docker state. Remote tear-down deletes persistent "
|
|
25
|
-
"application data for selected environments."
|
|
29
|
+
"application data for selected environments while preserving shared Project Forge platform state."
|
|
26
30
|
),
|
|
27
31
|
)
|
|
28
32
|
parser.add_argument(
|
|
@@ -38,6 +42,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
38
42
|
metavar="ENV=TARGET",
|
|
39
43
|
help="Explicit named environment-to-target binding; repeat as needed.",
|
|
40
44
|
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--public-ingress-scope",
|
|
47
|
+
action="append",
|
|
48
|
+
default=[],
|
|
49
|
+
metavar="ENV=SCOPE",
|
|
50
|
+
help=(
|
|
51
|
+
"Optional exact environment-to-public-ingress-scope binding discovered from target inventory; "
|
|
52
|
+
"repeat as needed. Selected public routes are removed before LAN/runtime cleanup."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
41
55
|
parser.add_argument(
|
|
42
56
|
"--production-environment",
|
|
43
57
|
action="append",
|
|
@@ -66,34 +80,84 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
66
80
|
f"{PRODUCTION_DATA_DELETE_PHRASE}"
|
|
67
81
|
),
|
|
68
82
|
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--public-ingress-authorization",
|
|
85
|
+
help=(
|
|
86
|
+
"Additional phrase required whenever selected environments include public ingress cleanup: "
|
|
87
|
+
f"{PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE}"
|
|
88
|
+
),
|
|
89
|
+
)
|
|
69
90
|
return parser.parse_args(argv)
|
|
70
91
|
|
|
71
92
|
|
|
72
93
|
def _preview(spec: dict[str, object]) -> dict[str, object]:
|
|
73
94
|
production = bool(spec["productionEnvironments"])
|
|
95
|
+
public_ingress = bool(spec["publicIngressScopes"])
|
|
74
96
|
return {
|
|
75
|
-
"schemaVersion":
|
|
97
|
+
"schemaVersion": 3,
|
|
76
98
|
"ownerRepo": spec["ownerRepo"],
|
|
77
99
|
"appSlug": spec["appSlug"],
|
|
78
100
|
"scope": spec["scope"],
|
|
79
101
|
"environmentTargets": spec["environmentTargets"],
|
|
102
|
+
"publicIngressScopes": spec["publicIngressScopes"],
|
|
80
103
|
"localPackage": spec["localPackage"],
|
|
81
104
|
"productionEnvironments": spec["productionEnvironments"],
|
|
82
105
|
"destructive": True,
|
|
83
106
|
"deletesPersistentData": bool(spec["environmentTargets"]),
|
|
84
107
|
"deletesProductionData": production,
|
|
108
|
+
"removesPublicIngress": public_ingress,
|
|
109
|
+
"removesUnreferencedRemoteImages": bool(spec["environmentTargets"]),
|
|
85
110
|
"preservesSharedPlatform": True,
|
|
111
|
+
"preservesSharedPublicIngressRuntime": True,
|
|
86
112
|
"preservesUnrelatedProjects": True,
|
|
87
113
|
"deletesGhcrPackage": False,
|
|
88
114
|
"requiredAuthorization": TEAR_DOWN_WRITE_PHRASE,
|
|
89
115
|
"requiredProductionDataAuthorization": PRODUCTION_DATA_DELETE_PHRASE if production else None,
|
|
116
|
+
"requiredPublicIngressAuthorization": (
|
|
117
|
+
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE if public_ingress else None
|
|
118
|
+
),
|
|
90
119
|
}
|
|
91
120
|
|
|
92
121
|
|
|
122
|
+
def inventory_main(argv: list[str] | None = None) -> int:
|
|
123
|
+
parser = argparse.ArgumentParser(
|
|
124
|
+
prog="project-forge-deploy target inventory",
|
|
125
|
+
description=(
|
|
126
|
+
"Read shared platform state plus application-owned resources that are valid tear-down candidates "
|
|
127
|
+
"on one target. This command is read-only and does not require a consumer repository."
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument("name", help="Logical Project Forge target profile")
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--ingress-scope",
|
|
133
|
+
action="append",
|
|
134
|
+
default=[],
|
|
135
|
+
metavar="SCOPE",
|
|
136
|
+
help=(
|
|
137
|
+
"Additional public-ingress scope to inspect when it cannot be inferred from managed application "
|
|
138
|
+
"or LAN-route environment names; repeat as needed."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument("--json", action="store_true")
|
|
142
|
+
try:
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
result = target_inventory(args.name, ingress_scopes=args.ingress_scope)
|
|
145
|
+
if args.json:
|
|
146
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
147
|
+
else:
|
|
148
|
+
print("TARGET INVENTORY")
|
|
149
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
150
|
+
return 0
|
|
151
|
+
except DeploymentKitError as exc:
|
|
152
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
153
|
+
return 2
|
|
154
|
+
|
|
155
|
+
|
|
93
156
|
def main(argv: list[str] | None = None) -> int:
|
|
94
157
|
try:
|
|
95
158
|
args = parse_args(argv)
|
|
96
159
|
bindings = parse_environment_targets(args.environment_target)
|
|
160
|
+
public_bindings = parse_environment_scopes(args.public_ingress_scope)
|
|
97
161
|
spec = normalize_teardown_spec(
|
|
98
162
|
owner_repo=args.owner_repo,
|
|
99
163
|
app_slug=args.app_slug,
|
|
@@ -101,6 +165,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
101
165
|
environment_targets=bindings,
|
|
102
166
|
local_package=args.local_package,
|
|
103
167
|
production_environments=args.production_environment,
|
|
168
|
+
public_ingress_scopes=public_bindings,
|
|
104
169
|
)
|
|
105
170
|
preview = _preview(spec)
|
|
106
171
|
|
|
@@ -110,7 +175,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
110
175
|
needs_production
|
|
111
176
|
and args.production_data_authorization != PRODUCTION_DATA_DELETE_PHRASE
|
|
112
177
|
)
|
|
113
|
-
|
|
178
|
+
needs_public_ingress = bool(spec["publicIngressScopes"])
|
|
179
|
+
missing_public_ingress = (
|
|
180
|
+
needs_public_ingress
|
|
181
|
+
and args.public_ingress_authorization != PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE
|
|
182
|
+
)
|
|
183
|
+
if missing_general or missing_production or missing_public_ingress:
|
|
114
184
|
print("PROJECT TEAR-DOWN PREVIEW")
|
|
115
185
|
print(render_teardown_summary(preview))
|
|
116
186
|
if missing_general:
|
|
@@ -124,6 +194,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
124
194
|
f"--production-data-authorization {PRODUCTION_DATA_DELETE_PHRASE!r}",
|
|
125
195
|
file=sys.stderr,
|
|
126
196
|
)
|
|
197
|
+
if missing_public_ingress:
|
|
198
|
+
print(
|
|
199
|
+
"ERROR: public-ingress removal requires "
|
|
200
|
+
f"--public-ingress-authorization {PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE!r}",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
127
203
|
return 2
|
|
128
204
|
|
|
129
205
|
result = tear_down_project(spec)
|
|
@@ -10,6 +10,7 @@ ENVIRONMENT="$3"
|
|
|
10
10
|
|
|
11
11
|
valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
|
|
12
12
|
valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
|
|
13
|
+
valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]]; }
|
|
13
14
|
meta_value() {
|
|
14
15
|
local path="$1" key="$2"
|
|
15
16
|
[[ -f "$path" && ! -L "$path" ]] || return 1
|
|
@@ -22,6 +23,13 @@ assert_owned_marker() {
|
|
|
22
23
|
"$(meta_value "$path" APP_SLUG || true)" == "$APP" && \
|
|
23
24
|
"$(meta_value "$path" ENVIRONMENT || true)" == "$ENVIRONMENT" ]]
|
|
24
25
|
}
|
|
26
|
+
append_unique_image() {
|
|
27
|
+
local value="$1" existing
|
|
28
|
+
for existing in "${IMAGES[@]:-}"; do
|
|
29
|
+
[[ "$existing" == "$value" ]] && return 0
|
|
30
|
+
done
|
|
31
|
+
IMAGES+=("$value")
|
|
32
|
+
}
|
|
25
33
|
|
|
26
34
|
valid_repo "$OWNER" || { echo "Invalid owner repository." >&2; exit 20; }
|
|
27
35
|
valid_slug "$APP" || { echo "Invalid application slug." >&2; exit 20; }
|
|
@@ -60,6 +68,23 @@ if [[ "$HAS_PERSISTENCE" == true ]] && ! assert_owned_marker "$PERSISTENCE"; the
|
|
|
60
68
|
fi
|
|
61
69
|
|
|
62
70
|
CONTAINER=""
|
|
71
|
+
IMAGES=()
|
|
72
|
+
if [[ -d "$ROOT/releases" && ! -L "$ROOT/releases" ]]; then
|
|
73
|
+
while IFS= read -r release_file; do
|
|
74
|
+
[[ -f "$release_file" && ! -L "$release_file" ]] || continue
|
|
75
|
+
assert_owned_marker "$release_file" || {
|
|
76
|
+
echo "Managed release ownership does not match selected environment: $release_file" >&2
|
|
77
|
+
exit 23
|
|
78
|
+
}
|
|
79
|
+
image="$(meta_value "$release_file" IMAGE || true)"
|
|
80
|
+
valid_image "$image" || {
|
|
81
|
+
echo "Managed release contains an invalid immutable image reference: $release_file" >&2
|
|
82
|
+
exit 23
|
|
83
|
+
}
|
|
84
|
+
append_unique_image "$image"
|
|
85
|
+
done < <(find "$ROOT/releases" -mindepth 2 -maxdepth 2 -type f -name release.env -print 2>/dev/null || true)
|
|
86
|
+
fi
|
|
87
|
+
|
|
63
88
|
if [[ "$HAS_STATE" == true ]]; then
|
|
64
89
|
CURRENT_RELEASE="$(meta_value "$STATE" CURRENT_RELEASE || true)"
|
|
65
90
|
[[ -n "$CURRENT_RELEASE" && "$CURRENT_RELEASE" != */* && "$CURRENT_RELEASE" != *..* ]] || {
|
|
@@ -96,5 +121,37 @@ if [[ -d "$APP_DIR" ]] && ! find "$APP_DIR" -mindepth 1 -print -quit | grep -q .
|
|
|
96
121
|
rmdir "$APP_DIR"
|
|
97
122
|
fi
|
|
98
123
|
|
|
99
|
-
|
|
124
|
+
RETAINED_IMAGE=false
|
|
125
|
+
for IMAGE in "${IMAGES[@]:-}"; do
|
|
126
|
+
[[ -n "$IMAGE" ]] || continue
|
|
127
|
+
IMAGE_REFERENCED_ELSEWHERE=false
|
|
128
|
+
while IFS= read -r candidate; do
|
|
129
|
+
[[ -f "$candidate" && ! -L "$candidate" ]] || continue
|
|
130
|
+
if [[ "$(meta_value "$candidate" IMAGE || true)" == "$IMAGE" ]]; then
|
|
131
|
+
IMAGE_REFERENCED_ELSEWHERE=true
|
|
132
|
+
break
|
|
133
|
+
fi
|
|
134
|
+
done < <(find "$APP_ROOT" -mindepth 5 -maxdepth 5 -type f -name release.env -print 2>/dev/null || true)
|
|
135
|
+
|
|
136
|
+
if [[ "$IMAGE_REFERENCED_ELSEWHERE" == true ]] || \
|
|
137
|
+
docker ps -a --filter "ancestor=$IMAGE" --format '{{.ID}}' | grep -q .; then
|
|
138
|
+
RETAINED_IMAGE=true
|
|
139
|
+
continue
|
|
140
|
+
fi
|
|
141
|
+
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
142
|
+
if ! docker image rm "$IMAGE" >/dev/null 2>&1; then
|
|
143
|
+
RETAINED_IMAGE=true
|
|
144
|
+
fi
|
|
145
|
+
fi
|
|
146
|
+
done
|
|
147
|
+
|
|
148
|
+
IMAGES_CSV=""
|
|
149
|
+
if [[ ${#IMAGES[@]} -gt 0 ]]; then
|
|
150
|
+
IMAGES_CSV="$(IFS=,; echo "${IMAGES[*]}")"
|
|
151
|
+
fi
|
|
152
|
+
ALL_IMAGES_REMOVED=true
|
|
153
|
+
[[ "$RETAINED_IMAGE" == true ]] && ALL_IMAGES_REMOVED=false
|
|
154
|
+
|
|
155
|
+
printf 'TEAR_DOWN\tremoved\t%s\t%s\t%s\t%s\t%s\n' \
|
|
156
|
+
"$APP" "$ENVIRONMENT" "$CONTAINER" "$IMAGES_CSV" "$ALL_IMAGES_REMOVED"
|
|
100
157
|
'''
|