@mriekki/project-forge 0.0.0-stage → 2.0.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/README.md +134 -2
- package/assets/components.json +33 -0
- package/assets/core/distribution.json +66 -0
- package/assets/deployment/distribution.json +54 -0
- package/assets/generated/ASSEMBLY.json +557 -0
- package/assets/generated/core/core/agents/coach.agent.md +69 -0
- package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
- package/assets/generated/core/core/instructions/project-forge-docs.instructions.md +45 -0
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +79 -0
- package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
- package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +59 -0
- package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +108 -0
- package/assets/generated/core/core/learning/LEARNING_MAP.md +108 -0
- package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +39 -0
- package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
- package/assets/generated/core/core/learning/SQL_DRILLS.md +90 -0
- package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +301 -0
- package/assets/generated/core/core/process/COACHING_GUIDE.md +92 -0
- package/assets/generated/core/core/process/DATABASE_LEARNING_WORKBENCH.md +397 -0
- package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +166 -0
- package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
- package/assets/generated/core/core/process/HUMAN_GATES.md +83 -0
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +64 -0
- package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +326 -0
- package/assets/generated/core/core/process/PROJECT_FORGE_ENGINEERING_GUIDE.md +308 -0
- package/assets/generated/core/core/process/PR_WORKFLOW.md +55 -0
- package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
- package/assets/generated/core/core/process/SYSTEM_DESIGN_LEARNING_WORKBENCH.md +520 -0
- package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
- package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
- package/assets/generated/core/core/skills/coach/SKILL.md +174 -0
- package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
- package/assets/generated/core/core/skills/core-sync/SKILL.md +178 -0
- package/assets/generated/core/core/skills/db-lab/SKILL.md +245 -0
- package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
- package/assets/generated/core/core/skills/deliver/SKILL.md +397 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +188 -0
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +234 -0
- package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +231 -0
- package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
- package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
- package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
- package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
- package/assets/generated/core/core/skills/next-step/SKILL.md +221 -0
- package/assets/generated/core/core/skills/progress/SKILL.md +207 -0
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +228 -0
- package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
- package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
- package/assets/generated/core/core/skills/setup/SKILL.md +362 -0
- package/assets/generated/core/core/skills/start-work/SKILL.md +190 -0
- package/assets/generated/core/core/skills/system-lab/SKILL.md +333 -0
- package/assets/generated/core/core/skills/tear-down/SKILL.md +140 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +221 -0
- package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
- package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
- package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
- package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
- package/assets/generated/core/profiles/greenfield/project-forge-workspace.json +12 -0
- package/assets/generated/core/tools/db_lab/sqlite_workbench.py +463 -0
- package/assets/generated/core/tools/docs_site/docs_site.py +512 -0
- package/assets/generated/core/tools/package/check_baseline.py +102 -0
- package/assets/generated/core/tools/package/check_upstream.py +171 -0
- package/assets/generated/core/tools/system_lab/system_workbench.py +243 -0
- package/assets/generated/deployment/distribution/project-forge-deploy.py +8 -0
- package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +335 -0
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +95 -0
- package/assets/generated/deployment/docs/WORKSPACE_INSTANCES.md +154 -0
- package/assets/generated/deployment/docs/images/developer-environment-journey.svg +56 -0
- package/assets/generated/deployment/docs/images/personal-environment-architecture.svg +54 -0
- package/assets/generated/deployment/docs/images/poc-environment-model.svg +61 -0
- package/assets/generated/deployment/src/project_forge_deployment/__init__.py +3 -0
- package/assets/generated/deployment/src/project_forge_deployment/__main__.py +3 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +163 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +455 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +416 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +136 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform.py +208 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform_assets.py +100 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform_remote.py +271 -0
- package/assets/generated/deployment/src/project_forge_deployment/cli.py +437 -0
- package/assets/generated/deployment/src/project_forge_deployment/doctor.py +121 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +37 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +271 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +228 -0
- package/assets/generated/deployment/src/project_forge_deployment/errors.py +5 -0
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +328 -0
- package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +105 -0
- package/assets/generated/deployment/src/project_forge_deployment/keys.py +171 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_cli.py +189 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_runtime.py +526 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_spec.py +82 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_sqlite.py +205 -0
- package/assets/generated/deployment/src/project_forge_deployment/portable_profile.py +181 -0
- package/assets/generated/deployment/src/project_forge_deployment/profile_migration.py +70 -0
- package/assets/generated/deployment/src/project_forge_deployment/profile_repository.py +348 -0
- package/assets/generated/deployment/src/project_forge_deployment/profiles.py +204 -0
- package/assets/generated/deployment/src/project_forge_deployment/registry_access.py +114 -0
- package/assets/generated/deployment/src/project_forge_deployment/registry_cli.py +51 -0
- package/assets/generated/deployment/src/project_forge_deployment/route_registry.py +367 -0
- package/assets/generated/deployment/src/project_forge_deployment/route_registry_remote.py +443 -0
- package/assets/generated/deployment/src/project_forge_deployment/security.py +290 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +335 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +340 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +86 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_import_recovery.py +437 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +119 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown.py +300 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +139 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +100 -0
- package/assets/generated/deployment/src/project_forge_deployment/trust.py +278 -0
- package/bin/project-forge.mjs +9 -0
- package/package.json +31 -4
- package/src/cli.mjs +150 -0
- package/src/engine.mjs +243 -0
- package/src/package-data.mjs +18 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""CLI for portable developer environment definitions and Home Lab onboarding."""
|
|
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 .environment_probe import (
|
|
12
|
+
DEFAULT_PROBE_IMAGE,
|
|
13
|
+
ENVIRONMENT_PROOF_WRITE_PHRASE,
|
|
14
|
+
PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE,
|
|
15
|
+
plan_environment_proof,
|
|
16
|
+
run_environment_proof,
|
|
17
|
+
)
|
|
18
|
+
from .errors import DeploymentKitError
|
|
19
|
+
from .home_lab import HOME_LAB_BOOTSTRAP_PHRASE, apply_home_lab_bootstrap, plan_home_lab_bootstrap
|
|
20
|
+
from .image_transfer import transfer_image, verify_local_image
|
|
21
|
+
from .portable_profile import apply_import, export_bundle, load_bundle, plan_import, write_bundle
|
|
22
|
+
from .profile_migration import PERSONAL_PROFILE_MIGRATION_PHRASE, migrate_current_workstation
|
|
23
|
+
from .profile_repository import (
|
|
24
|
+
PERSONAL_PROFILE_WRITE_PHRASE,
|
|
25
|
+
ensure_profile_repository,
|
|
26
|
+
publish_profile,
|
|
27
|
+
repository_status,
|
|
28
|
+
sync_profile,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
LOCAL_PROFILE_IMPORT_PHRASE = "[LOCAL PROFILE IMPORT]"
|
|
33
|
+
REMOTE_IMAGE_TRANSFER_PHRASE = "[REMOTE IMAGE TRANSFER]"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _print(value: Any, *, as_json: bool = False) -> None:
|
|
37
|
+
if as_json:
|
|
38
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
39
|
+
return
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
for key, item in value.items():
|
|
42
|
+
if isinstance(item, (dict, list)):
|
|
43
|
+
print(f"{key}: {json.dumps(item, sort_keys=True)}")
|
|
44
|
+
else:
|
|
45
|
+
print(f"{key}: {item}")
|
|
46
|
+
return
|
|
47
|
+
print(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
51
|
+
if getattr(args, "yes", False):
|
|
52
|
+
return
|
|
53
|
+
entered = input(f"Type {phrase} to {action}: ").strip()
|
|
54
|
+
if entered != phrase:
|
|
55
|
+
raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
59
|
+
parser = argparse.ArgumentParser(prog="project-forge-deploy environment")
|
|
60
|
+
groups = parser.add_subparsers(dest="group", required=True)
|
|
61
|
+
|
|
62
|
+
profile = groups.add_parser("profile", help="Personal/portable non-secret target profile operations")
|
|
63
|
+
profile_sub = profile.add_subparsers(dest="command", required=True)
|
|
64
|
+
|
|
65
|
+
status = profile_sub.add_parser("status", help="Inspect the authenticated developer's private project-forge-profile repository")
|
|
66
|
+
status.add_argument("--json", action="store_true")
|
|
67
|
+
|
|
68
|
+
ensure = profile_sub.add_parser(
|
|
69
|
+
"ensure",
|
|
70
|
+
help="Create/initialize the authenticated developer's private project-forge-profile repository when missing",
|
|
71
|
+
)
|
|
72
|
+
ensure.add_argument("--json", action="store_true")
|
|
73
|
+
ensure.add_argument("--yes", action="store_true")
|
|
74
|
+
|
|
75
|
+
migrate = profile_sub.add_parser(
|
|
76
|
+
"migrate",
|
|
77
|
+
help="One-time adoption of this workstation's existing target definitions into the personal profile model",
|
|
78
|
+
)
|
|
79
|
+
migrate.add_argument("--json", action="store_true")
|
|
80
|
+
migrate.add_argument("--yes", action="store_true")
|
|
81
|
+
|
|
82
|
+
sync = profile_sub.add_parser(
|
|
83
|
+
"sync",
|
|
84
|
+
help="Fetch/validate the personal profile, update last-known-good cache, and optionally import missing definitions",
|
|
85
|
+
)
|
|
86
|
+
sync.add_argument("--apply", action="store_true", help="Create only missing non-conflicting local target profiles")
|
|
87
|
+
sync.add_argument("--json", action="store_true")
|
|
88
|
+
|
|
89
|
+
publish = profile_sub.add_parser(
|
|
90
|
+
"publish",
|
|
91
|
+
help="Merge new non-conflicting local target definitions into the private personal profile",
|
|
92
|
+
)
|
|
93
|
+
publish.add_argument("--json", action="store_true")
|
|
94
|
+
publish.add_argument("--yes", action="store_true")
|
|
95
|
+
|
|
96
|
+
export = profile_sub.add_parser("export", help="Export sanitized target definitions to a portable bundle")
|
|
97
|
+
export.add_argument("--target", action="append", default=[])
|
|
98
|
+
export.add_argument("--output", type=Path, required=True)
|
|
99
|
+
export.add_argument("--json", action="store_true")
|
|
100
|
+
|
|
101
|
+
preview = profile_sub.add_parser("preview-import", help="Preview a portable bundle without writing local profiles")
|
|
102
|
+
preview.add_argument("bundle", type=Path)
|
|
103
|
+
preview.add_argument("--json", action="store_true")
|
|
104
|
+
|
|
105
|
+
apply = profile_sub.add_parser("import", help="Import only non-conflicting portable target definitions")
|
|
106
|
+
apply.add_argument("bundle", type=Path)
|
|
107
|
+
apply.add_argument("--json", action="store_true")
|
|
108
|
+
apply.add_argument("--yes", action="store_true")
|
|
109
|
+
|
|
110
|
+
home_lab = groups.add_parser(
|
|
111
|
+
"home-lab",
|
|
112
|
+
help="Password-assisted one-time SSH bootstrap followed by strict per-workstation key access",
|
|
113
|
+
)
|
|
114
|
+
home_lab_sub = home_lab.add_subparsers(dest="command", required=True)
|
|
115
|
+
for command in ("plan", "bootstrap"):
|
|
116
|
+
sub = home_lab_sub.add_parser(command)
|
|
117
|
+
sub.add_argument("name")
|
|
118
|
+
sub.add_argument("--host-name", required=True)
|
|
119
|
+
sub.add_argument("--user", required=True)
|
|
120
|
+
sub.add_argument("--port", type=int, default=22)
|
|
121
|
+
sub.add_argument("--ssh-alias")
|
|
122
|
+
sub.add_argument("--address", help="Optional exact temporary IP; stable host identity remains --host-name/alias")
|
|
123
|
+
sub.add_argument(
|
|
124
|
+
"--protect-key-with-passphrase",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="Let ssh-keygen prompt locally for a key passphrase; default Home Lab key is unencrypted",
|
|
127
|
+
)
|
|
128
|
+
sub.add_argument("--json", action="store_true")
|
|
129
|
+
if command == "bootstrap":
|
|
130
|
+
sub.add_argument("--yes", action="store_true")
|
|
131
|
+
|
|
132
|
+
image = groups.add_parser("image", help="Workstation-mediated exact Docker image transfer")
|
|
133
|
+
image_sub = image.add_subparsers(dest="command", required=True)
|
|
134
|
+
image_check = image_sub.add_parser("check", help="Verify an exact immutable image exists locally")
|
|
135
|
+
image_check.add_argument("--image", required=True)
|
|
136
|
+
image_check.add_argument("--json", action="store_true")
|
|
137
|
+
image_transfer = image_sub.add_parser("transfer", help="Stream an exact local image to a verified target over SSH")
|
|
138
|
+
image_transfer.add_argument("name")
|
|
139
|
+
image_transfer.add_argument("--image", required=True)
|
|
140
|
+
image_transfer.add_argument("--json", action="store_true")
|
|
141
|
+
image_transfer.add_argument("--yes", action="store_true")
|
|
142
|
+
|
|
143
|
+
proof = groups.add_parser(
|
|
144
|
+
"proof",
|
|
145
|
+
help="Plan/run a disposable end-to-end deployment proof for one named remote environment",
|
|
146
|
+
)
|
|
147
|
+
proof_sub = proof.add_subparsers(dest="command", required=True)
|
|
148
|
+
for command in ("plan", "run"):
|
|
149
|
+
sub = proof_sub.add_parser(command)
|
|
150
|
+
sub.add_argument("name", help="Logical target profile name")
|
|
151
|
+
sub.add_argument("--owner-repo", required=True)
|
|
152
|
+
sub.add_argument("--environment", required=True)
|
|
153
|
+
sub.add_argument(
|
|
154
|
+
"--production",
|
|
155
|
+
action="store_true",
|
|
156
|
+
help="Classify the named environment as Production even when its name is not literally 'production'",
|
|
157
|
+
)
|
|
158
|
+
sub.add_argument("--probe-image", default=DEFAULT_PROBE_IMAGE)
|
|
159
|
+
sub.add_argument("--json", action="store_true")
|
|
160
|
+
if command == "run":
|
|
161
|
+
sub.add_argument("--yes", action="store_true")
|
|
162
|
+
|
|
163
|
+
return parser
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _home_lab_plan(args: argparse.Namespace) -> dict[str, Any]:
|
|
167
|
+
return plan_home_lab_bootstrap(
|
|
168
|
+
args.name,
|
|
169
|
+
host_name=args.host_name,
|
|
170
|
+
user=args.user,
|
|
171
|
+
port=args.port,
|
|
172
|
+
ssh_alias=args.ssh_alias,
|
|
173
|
+
address=args.address,
|
|
174
|
+
protect_key_with_passphrase=args.protect_key_with_passphrase,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main(argv: list[str] | None = None) -> int:
|
|
179
|
+
args = build_parser().parse_args(argv)
|
|
180
|
+
try:
|
|
181
|
+
if args.group == "profile" and args.command == "status":
|
|
182
|
+
_print(repository_status(), as_json=args.json)
|
|
183
|
+
return 0
|
|
184
|
+
if args.group == "profile" and args.command == "ensure":
|
|
185
|
+
_print(repository_status(), as_json=False)
|
|
186
|
+
_confirm(args, PERSONAL_PROFILE_WRITE_PHRASE, "create/initialize the private personal Project Forge profile when needed")
|
|
187
|
+
_print(ensure_profile_repository(), as_json=args.json)
|
|
188
|
+
return 0
|
|
189
|
+
if args.group == "profile" and args.command == "migrate":
|
|
190
|
+
_confirm(args, PERSONAL_PROFILE_MIGRATION_PHRASE, "migrate this workstation to the personal Project Forge profile model")
|
|
191
|
+
_print(migrate_current_workstation(), as_json=args.json)
|
|
192
|
+
return 0
|
|
193
|
+
if args.group == "profile" and args.command == "sync":
|
|
194
|
+
_print(sync_profile(apply=args.apply), as_json=args.json)
|
|
195
|
+
return 0
|
|
196
|
+
if args.group == "profile" and args.command == "publish":
|
|
197
|
+
_confirm(args, PERSONAL_PROFILE_WRITE_PHRASE, "publish non-secret target definitions to the private personal Project Forge profile")
|
|
198
|
+
_print(publish_profile(), as_json=args.json)
|
|
199
|
+
return 0
|
|
200
|
+
if args.group == "profile" and args.command == "export":
|
|
201
|
+
bundle = export_bundle(args.target or None)
|
|
202
|
+
path = write_bundle(bundle, args.output)
|
|
203
|
+
_print({"path": str(path), "targets": [item["targetName"] for item in bundle["targets"]]}, as_json=args.json)
|
|
204
|
+
return 0
|
|
205
|
+
if args.group == "profile" and args.command == "preview-import":
|
|
206
|
+
_print(plan_import(load_bundle(args.bundle)), as_json=args.json)
|
|
207
|
+
return 0
|
|
208
|
+
if args.group == "profile" and args.command == "import":
|
|
209
|
+
plan = plan_import(load_bundle(args.bundle))
|
|
210
|
+
_print(plan, as_json=False)
|
|
211
|
+
if plan["hasConflicts"]:
|
|
212
|
+
raise DeploymentKitError("Import conflicts must be resolved explicitly; no local profiles were changed.")
|
|
213
|
+
_confirm(args, LOCAL_PROFILE_IMPORT_PHRASE, "import these portable target definitions")
|
|
214
|
+
_print(apply_import(plan), as_json=args.json)
|
|
215
|
+
return 0
|
|
216
|
+
if args.group == "home-lab":
|
|
217
|
+
plan = _home_lab_plan(args)
|
|
218
|
+
_print(plan, as_json=args.json if args.command == "plan" else False)
|
|
219
|
+
if args.command == "plan":
|
|
220
|
+
return 0
|
|
221
|
+
_confirm(args, HOME_LAB_BOOTSTRAP_PHRASE, f"bootstrap Home Lab access for target {args.name}")
|
|
222
|
+
_print(
|
|
223
|
+
apply_home_lab_bootstrap(
|
|
224
|
+
plan,
|
|
225
|
+
protect_key_with_passphrase=args.protect_key_with_passphrase,
|
|
226
|
+
),
|
|
227
|
+
as_json=args.json,
|
|
228
|
+
)
|
|
229
|
+
return 0
|
|
230
|
+
if args.group == "image" and args.command == "check":
|
|
231
|
+
_print(verify_local_image(args.image), as_json=args.json)
|
|
232
|
+
return 0
|
|
233
|
+
if args.group == "image" and args.command == "transfer":
|
|
234
|
+
_print(verify_local_image(args.image), as_json=False)
|
|
235
|
+
_confirm(args, REMOTE_IMAGE_TRANSFER_PHRASE, f"transfer this exact image to target {args.name}")
|
|
236
|
+
_print(transfer_image(args.name, args.image), as_json=args.json)
|
|
237
|
+
return 0
|
|
238
|
+
if args.group == "proof":
|
|
239
|
+
plan = plan_environment_proof(
|
|
240
|
+
args.name,
|
|
241
|
+
owner_repo=args.owner_repo,
|
|
242
|
+
environment=args.environment,
|
|
243
|
+
production=args.production,
|
|
244
|
+
probe_image=args.probe_image,
|
|
245
|
+
)
|
|
246
|
+
_print(plan, as_json=args.json if args.command == "plan" else False)
|
|
247
|
+
if args.command == "plan":
|
|
248
|
+
return 0
|
|
249
|
+
_confirm(
|
|
250
|
+
args,
|
|
251
|
+
plan["requiredAuthorization"],
|
|
252
|
+
f"run a disposable deployment proof for {plan['environment']} on target {plan['targetName']}",
|
|
253
|
+
)
|
|
254
|
+
_print(run_environment_proof(plan), as_json=args.json)
|
|
255
|
+
return 0
|
|
256
|
+
raise DeploymentKitError("Unsupported environment command.")
|
|
257
|
+
except DeploymentKitError as error:
|
|
258
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
259
|
+
return 2
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = [
|
|
263
|
+
"ENVIRONMENT_PROOF_WRITE_PHRASE",
|
|
264
|
+
"PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE",
|
|
265
|
+
"build_parser",
|
|
266
|
+
"main",
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__":
|
|
271
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Disposable end-to-end proof for one named remote deployment environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .app_runtime import normalize_app_spec, plan_app_deploy
|
|
13
|
+
from .app_runtime_workstation import apply_app_deploy_from_workstation
|
|
14
|
+
from .box_platform import platform_status
|
|
15
|
+
from .errors import DeploymentKitError
|
|
16
|
+
from .registry_access import normalize_registry_image
|
|
17
|
+
from .teardown import tear_down_poc
|
|
18
|
+
|
|
19
|
+
ENVIRONMENT_PROOF_WRITE_PHRASE = "[ENVIRONMENT PROOF WRITE]"
|
|
20
|
+
PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE = "[PRODUCTION ENVIRONMENT PROOF]"
|
|
21
|
+
DEFAULT_PROBE_IMAGE = "nginx:alpine"
|
|
22
|
+
|
|
23
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
24
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _slug(value: str, field: str) -> str:
|
|
28
|
+
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
29
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _owner_repo(value: str) -> str:
|
|
34
|
+
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
35
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _docker() -> str:
|
|
40
|
+
path = shutil.which("docker")
|
|
41
|
+
if path is None:
|
|
42
|
+
raise DeploymentKitError("Docker CLI is unavailable on the workstation.")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _probe_slug(owner_repo: str) -> str:
|
|
47
|
+
digest = hashlib.sha256(owner_repo.encode("utf-8")).hexdigest()[:10]
|
|
48
|
+
return f"project-forge-env-probe-{digest}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_probe_image(image_tag: str) -> str:
|
|
52
|
+
if not isinstance(image_tag, str) or not image_tag.strip() or "@sha256:" in image_tag:
|
|
53
|
+
raise DeploymentKitError("probeImage must be a non-empty mutable image tag; Project Forge resolves it to an exact digest.")
|
|
54
|
+
docker = _docker()
|
|
55
|
+
pulled = subprocess.run(
|
|
56
|
+
[docker, "pull", image_tag],
|
|
57
|
+
check=False,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
errors="replace",
|
|
62
|
+
)
|
|
63
|
+
if pulled.returncode != 0:
|
|
64
|
+
raise DeploymentKitError(
|
|
65
|
+
"Environment proof image pull failed: " + (pulled.stderr or pulled.stdout).strip()
|
|
66
|
+
)
|
|
67
|
+
inspected = subprocess.run(
|
|
68
|
+
[docker, "image", "inspect", "--format", "{{json .RepoDigests}}", image_tag],
|
|
69
|
+
check=False,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
encoding="utf-8",
|
|
73
|
+
errors="replace",
|
|
74
|
+
)
|
|
75
|
+
if inspected.returncode != 0:
|
|
76
|
+
raise DeploymentKitError(
|
|
77
|
+
"Environment proof image digest inspection failed: " + (inspected.stderr or inspected.stdout).strip()
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
digests = json.loads(inspected.stdout.strip())
|
|
81
|
+
except json.JSONDecodeError as error:
|
|
82
|
+
raise DeploymentKitError("Environment proof image inspection returned malformed RepoDigests JSON.") from error
|
|
83
|
+
if not isinstance(digests, list):
|
|
84
|
+
raise DeploymentKitError("Environment proof image inspection did not return a RepoDigests list.")
|
|
85
|
+
for item in digests:
|
|
86
|
+
try:
|
|
87
|
+
return normalize_registry_image(item)
|
|
88
|
+
except DeploymentKitError:
|
|
89
|
+
continue
|
|
90
|
+
raise DeploymentKitError("Environment proof image has no usable immutable sha256 RepoDigest.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def plan_environment_proof(
|
|
94
|
+
target_name: str,
|
|
95
|
+
*,
|
|
96
|
+
owner_repo: str,
|
|
97
|
+
environment: str,
|
|
98
|
+
production: bool = False,
|
|
99
|
+
probe_image: str = DEFAULT_PROBE_IMAGE,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
owner = _owner_repo(owner_repo)
|
|
102
|
+
env = _slug(environment, "environment")
|
|
103
|
+
if not isinstance(production, bool):
|
|
104
|
+
raise DeploymentKitError("production must be true or false.")
|
|
105
|
+
if not isinstance(probe_image, str) or not probe_image.strip():
|
|
106
|
+
raise DeploymentKitError("probeImage must be a non-empty image tag.")
|
|
107
|
+
platform = platform_status(target_name)
|
|
108
|
+
if platform.get("state") != "ready":
|
|
109
|
+
raise DeploymentKitError(
|
|
110
|
+
f"Environment proof requires a ready shared platform; target '{target_name}' is '{platform.get('state', 'unknown')}'."
|
|
111
|
+
)
|
|
112
|
+
return {
|
|
113
|
+
"targetName": target_name,
|
|
114
|
+
"ownerRepo": owner,
|
|
115
|
+
"environment": env,
|
|
116
|
+
"production": production,
|
|
117
|
+
"probeAppSlug": _probe_slug(owner),
|
|
118
|
+
"probeImageTag": probe_image,
|
|
119
|
+
"artifactDelivery": "workstation-transfer",
|
|
120
|
+
"persistence": "none",
|
|
121
|
+
"healthPath": "/",
|
|
122
|
+
"cleanup": "automatic",
|
|
123
|
+
"requiredAuthorization": (
|
|
124
|
+
PRODUCTION_ENVIRONMENT_PROOF_WRITE_PHRASE if production else ENVIRONMENT_PROOF_WRITE_PHRASE
|
|
125
|
+
),
|
|
126
|
+
"mutated": False,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_environment_proof(plan: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
required = {
|
|
132
|
+
"targetName",
|
|
133
|
+
"ownerRepo",
|
|
134
|
+
"environment",
|
|
135
|
+
"production",
|
|
136
|
+
"probeAppSlug",
|
|
137
|
+
"probeImageTag",
|
|
138
|
+
"artifactDelivery",
|
|
139
|
+
"persistence",
|
|
140
|
+
"healthPath",
|
|
141
|
+
"cleanup",
|
|
142
|
+
"requiredAuthorization",
|
|
143
|
+
"mutated",
|
|
144
|
+
}
|
|
145
|
+
if not isinstance(plan, dict) or set(plan) != required or plan["mutated"] is not False:
|
|
146
|
+
raise DeploymentKitError("Malformed environment proof plan.")
|
|
147
|
+
|
|
148
|
+
exact_image = _resolve_probe_image(plan["probeImageTag"])
|
|
149
|
+
spec = normalize_app_spec(
|
|
150
|
+
owner_repo=plan["ownerRepo"],
|
|
151
|
+
environment=plan["environment"],
|
|
152
|
+
app_slug=plan["probeAppSlug"],
|
|
153
|
+
container_name=f"{plan['probeAppSlug']}--{plan['environment']}",
|
|
154
|
+
image=exact_image,
|
|
155
|
+
runtime_port=80,
|
|
156
|
+
health_path=plan["healthPath"],
|
|
157
|
+
run_as="0",
|
|
158
|
+
persistence_mode="none",
|
|
159
|
+
data_container_path=None,
|
|
160
|
+
environment_variables={},
|
|
161
|
+
)
|
|
162
|
+
deploy_plan = plan_app_deploy(plan["targetName"], spec)
|
|
163
|
+
|
|
164
|
+
deployment: dict[str, Any] | None = None
|
|
165
|
+
cleanup: dict[str, Any] | None = None
|
|
166
|
+
deploy_error: Exception | None = None
|
|
167
|
+
cleanup_error: Exception | None = None
|
|
168
|
+
try:
|
|
169
|
+
deployment = apply_app_deploy_from_workstation(deploy_plan)
|
|
170
|
+
except Exception as error: # cleanup must still be attempted after a partially applied proof
|
|
171
|
+
deploy_error = error
|
|
172
|
+
finally:
|
|
173
|
+
try:
|
|
174
|
+
cleanup = tear_down_poc(
|
|
175
|
+
{
|
|
176
|
+
"ownerRepo": plan["ownerRepo"],
|
|
177
|
+
"appSlug": plan["probeAppSlug"],
|
|
178
|
+
"scope": plan["environment"],
|
|
179
|
+
"environmentTargets": {plan["environment"]: plan["targetName"]},
|
|
180
|
+
"localPackage": None,
|
|
181
|
+
"productionEnvironments": [plan["environment"]] if plan["production"] else [],
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
except Exception as error:
|
|
185
|
+
cleanup_error = error
|
|
186
|
+
|
|
187
|
+
if deploy_error is not None:
|
|
188
|
+
detail = str(deploy_error)
|
|
189
|
+
if cleanup_error is not None:
|
|
190
|
+
detail += f" Cleanup also failed: {cleanup_error}"
|
|
191
|
+
raise DeploymentKitError(f"Environment proof deployment failed: {detail}") from deploy_error
|
|
192
|
+
if cleanup_error is not None:
|
|
193
|
+
raise DeploymentKitError(
|
|
194
|
+
f"Environment proof deployment succeeded but cleanup failed: {cleanup_error}"
|
|
195
|
+
) from cleanup_error
|
|
196
|
+
assert deployment is not None and cleanup is not None
|
|
197
|
+
|
|
198
|
+
matched = next(
|
|
199
|
+
(
|
|
200
|
+
item
|
|
201
|
+
for item in deployment.get("applications", [])
|
|
202
|
+
if item.get("appSlug") == plan["probeAppSlug"]
|
|
203
|
+
and item.get("environment") == plan["environment"]
|
|
204
|
+
),
|
|
205
|
+
None,
|
|
206
|
+
)
|
|
207
|
+
if not matched or not matched.get("containerRunning") or not matched.get("httpHealthy"):
|
|
208
|
+
raise DeploymentKitError("Environment proof returned without healthy disposable runtime evidence.")
|
|
209
|
+
cleanup_entry = next(
|
|
210
|
+
(item for item in cleanup.get("environments", []) if item.get("environment") == plan["environment"]),
|
|
211
|
+
None,
|
|
212
|
+
)
|
|
213
|
+
if cleanup_entry is None or cleanup_entry.get("runtimeAndPersistentData", {}).get("state") not in {"removed", "absent"}:
|
|
214
|
+
raise DeploymentKitError("Environment proof cleanup did not return terminal runtime cleanup evidence.")
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"targetName": plan["targetName"],
|
|
218
|
+
"ownerRepo": plan["ownerRepo"],
|
|
219
|
+
"environment": plan["environment"],
|
|
220
|
+
"production": plan["production"],
|
|
221
|
+
"probeAppSlug": plan["probeAppSlug"],
|
|
222
|
+
"probeImage": exact_image,
|
|
223
|
+
"artifactDelivery": "workstation-transfer",
|
|
224
|
+
"runtimeHealthy": True,
|
|
225
|
+
"cleanupState": cleanup_entry["runtimeAndPersistentData"]["state"],
|
|
226
|
+
"proof": "passed",
|
|
227
|
+
"mutated": True,
|
|
228
|
+
}
|