@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,348 @@
|
|
|
1
|
+
"""Personal GitHub project-forge-profile repository and validated local cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import DeploymentKitError
|
|
14
|
+
from .portable_profile import (
|
|
15
|
+
BUNDLE_KIND,
|
|
16
|
+
BUNDLE_SCHEMA_VERSION,
|
|
17
|
+
apply_import,
|
|
18
|
+
export_bundle,
|
|
19
|
+
plan_import,
|
|
20
|
+
validate_bundle,
|
|
21
|
+
)
|
|
22
|
+
from .profiles import config_root
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PROFILE_REPOSITORY = "project-forge-profile"
|
|
26
|
+
PROFILE_FILE = "project-forge-profile.json"
|
|
27
|
+
PERSONAL_PROFILE_WRITE_PHRASE = "[PERSONAL PROFILE WRITE]"
|
|
28
|
+
LOCAL_PROFILE_SYNC_PHRASE = "[LOCAL PROFILE SYNC]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _gh() -> str:
|
|
32
|
+
path = shutil.which("gh")
|
|
33
|
+
if path is None:
|
|
34
|
+
raise DeploymentKitError("GitHub CLI is unavailable; personal profile sync cannot reach GitHub.")
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_gh(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
39
|
+
return subprocess.run(
|
|
40
|
+
[_gh(), *args],
|
|
41
|
+
check=False,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
errors="replace",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _detail(result: subprocess.CompletedProcess[str]) -> str:
|
|
50
|
+
return result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _not_found(result: subprocess.CompletedProcess[str]) -> bool:
|
|
54
|
+
text = (result.stderr + "\n" + result.stdout).lower()
|
|
55
|
+
return result.returncode != 0 and (
|
|
56
|
+
"http 404" in text
|
|
57
|
+
or "not found" in text
|
|
58
|
+
or "could not resolve to a repository" in text
|
|
59
|
+
or "could not resolve to a resource" in text
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def authenticated_login() -> str:
|
|
64
|
+
result = _run_gh(["api", "user"])
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
raise DeploymentKitError(f"Cannot resolve authenticated GitHub identity: {_detail(result)}")
|
|
67
|
+
try:
|
|
68
|
+
value = json.loads(result.stdout)
|
|
69
|
+
except json.JSONDecodeError as error:
|
|
70
|
+
raise DeploymentKitError("GitHub CLI returned malformed authenticated-user data.") from error
|
|
71
|
+
login = value.get("login") if isinstance(value, dict) else None
|
|
72
|
+
if not isinstance(login, str) or not login:
|
|
73
|
+
raise DeploymentKitError("GitHub CLI authenticated-user data omitted login.")
|
|
74
|
+
return login
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def repository_status() -> dict[str, Any]:
|
|
78
|
+
login = authenticated_login()
|
|
79
|
+
full_name = f"{login}/{PROFILE_REPOSITORY}"
|
|
80
|
+
result = _run_gh(["api", f"repos/{full_name}"])
|
|
81
|
+
if _not_found(result):
|
|
82
|
+
return {
|
|
83
|
+
"login": login,
|
|
84
|
+
"repository": full_name,
|
|
85
|
+
"state": "missing",
|
|
86
|
+
"private": None,
|
|
87
|
+
"mutated": False,
|
|
88
|
+
}
|
|
89
|
+
if result.returncode != 0:
|
|
90
|
+
raise DeploymentKitError(f"Cannot inspect personal Project Forge profile repository: {_detail(result)}")
|
|
91
|
+
try:
|
|
92
|
+
value = json.loads(result.stdout)
|
|
93
|
+
except json.JSONDecodeError as error:
|
|
94
|
+
raise DeploymentKitError("GitHub CLI returned malformed repository data.") from error
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
raise DeploymentKitError("GitHub CLI returned malformed repository data.")
|
|
97
|
+
owner = (value.get("owner") or {}).get("login") if isinstance(value.get("owner"), dict) else None
|
|
98
|
+
name = value.get("name")
|
|
99
|
+
private = value.get("private")
|
|
100
|
+
if owner != login or name != PROFILE_REPOSITORY or type(private) is not bool:
|
|
101
|
+
raise DeploymentKitError("Personal Project Forge profile repository identity could not be verified.")
|
|
102
|
+
if not private:
|
|
103
|
+
raise DeploymentKitError(
|
|
104
|
+
f"Personal Project Forge profile repository {full_name} is public; it must be private before Project Forge will use it."
|
|
105
|
+
)
|
|
106
|
+
return {
|
|
107
|
+
"login": login,
|
|
108
|
+
"repository": full_name,
|
|
109
|
+
"state": "ready",
|
|
110
|
+
"private": True,
|
|
111
|
+
"mutated": False,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _profile_dirs() -> tuple[Path, Path, Path]:
|
|
116
|
+
root = config_root() / "profile"
|
|
117
|
+
return root / "current", root / "last-known-good", root / "sync-state.json"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _write_json_atomic(path: Path, value: Any) -> None:
|
|
121
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
if path.is_symlink() or path.parent.is_symlink():
|
|
123
|
+
raise DeploymentKitError(f"Refusing to write profile cache through a symbolic link: {path}")
|
|
124
|
+
temp = path.with_name(path.name + ".tmp")
|
|
125
|
+
temp.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
|
126
|
+
temp.replace(path)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cache_valid_profile(bundle: dict[str, Any], *, source: str, remote_sha: str | None) -> dict[str, Any]:
|
|
130
|
+
normalized = validate_bundle(bundle)
|
|
131
|
+
current, lkg, state_path = _profile_dirs()
|
|
132
|
+
current_file = current / PROFILE_FILE
|
|
133
|
+
lkg_file = lkg / PROFILE_FILE
|
|
134
|
+
_write_json_atomic(current_file, normalized)
|
|
135
|
+
_write_json_atomic(lkg_file, normalized)
|
|
136
|
+
state = {
|
|
137
|
+
"schemaVersion": 1,
|
|
138
|
+
"lastValidatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
139
|
+
"source": source,
|
|
140
|
+
"remoteSha": remote_sha,
|
|
141
|
+
"current": str(current_file),
|
|
142
|
+
"lastKnownGood": str(lkg_file),
|
|
143
|
+
}
|
|
144
|
+
_write_json_atomic(state_path, state)
|
|
145
|
+
return state
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_last_known_good() -> dict[str, Any]:
|
|
149
|
+
_, lkg, _ = _profile_dirs()
|
|
150
|
+
path = lkg / PROFILE_FILE
|
|
151
|
+
if path.is_symlink() or not path.is_file():
|
|
152
|
+
raise DeploymentKitError("No validated last-known-good personal Project Forge profile is cached on this workstation.")
|
|
153
|
+
try:
|
|
154
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
155
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
156
|
+
raise DeploymentKitError(f"Cannot read last-known-good personal Project Forge profile: {error}") from error
|
|
157
|
+
return validate_bundle(value)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _empty_bundle() -> dict[str, Any]:
|
|
161
|
+
return {"schemaVersion": BUNDLE_SCHEMA_VERSION, "kind": BUNDLE_KIND, "targets": []}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def fetch_remote_profile() -> dict[str, Any]:
|
|
165
|
+
status = repository_status()
|
|
166
|
+
if status["state"] != "ready":
|
|
167
|
+
raise DeploymentKitError(f"Personal Project Forge profile repository does not exist: {status['repository']}")
|
|
168
|
+
full_name = status["repository"]
|
|
169
|
+
result = _run_gh(["api", f"repos/{full_name}/contents/{PROFILE_FILE}"])
|
|
170
|
+
if _not_found(result):
|
|
171
|
+
return {"bundle": _empty_bundle(), "sha": None, "repository": full_name}
|
|
172
|
+
if result.returncode != 0:
|
|
173
|
+
raise DeploymentKitError(f"Cannot fetch personal Project Forge profile: {_detail(result)}")
|
|
174
|
+
try:
|
|
175
|
+
value = json.loads(result.stdout)
|
|
176
|
+
encoded = value["content"]
|
|
177
|
+
sha = value["sha"]
|
|
178
|
+
if value.get("encoding") != "base64" or not isinstance(encoded, str) or not isinstance(sha, str):
|
|
179
|
+
raise ValueError("unexpected content payload")
|
|
180
|
+
decoded = base64.b64decode(encoded.encode("ascii"), validate=False).decode("utf-8")
|
|
181
|
+
bundle = validate_bundle(json.loads(decoded))
|
|
182
|
+
except (KeyError, TypeError, ValueError, UnicodeError, json.JSONDecodeError) as error:
|
|
183
|
+
raise DeploymentKitError(
|
|
184
|
+
"Remote personal Project Forge profile is invalid; the validated local last-known-good profile was not replaced."
|
|
185
|
+
) from error
|
|
186
|
+
return {"bundle": bundle, "sha": sha, "repository": full_name}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sync_profile(*, apply: bool) -> dict[str, Any]:
|
|
190
|
+
try:
|
|
191
|
+
remote = fetch_remote_profile()
|
|
192
|
+
state = cache_valid_profile(remote["bundle"], source="github", remote_sha=remote["sha"])
|
|
193
|
+
bundle = remote["bundle"]
|
|
194
|
+
source = "github"
|
|
195
|
+
remote_error = None
|
|
196
|
+
except DeploymentKitError as error:
|
|
197
|
+
bundle = load_last_known_good()
|
|
198
|
+
state = {"source": "last-known-good"}
|
|
199
|
+
source = "last-known-good"
|
|
200
|
+
remote_error = str(error)
|
|
201
|
+
|
|
202
|
+
plan = plan_import(bundle)
|
|
203
|
+
result: dict[str, Any] = {
|
|
204
|
+
"source": source,
|
|
205
|
+
"targetCount": len(bundle["targets"]),
|
|
206
|
+
"importPlan": plan,
|
|
207
|
+
"cache": state,
|
|
208
|
+
"remoteError": remote_error,
|
|
209
|
+
"mutated": False,
|
|
210
|
+
}
|
|
211
|
+
if apply and not plan["hasConflicts"]:
|
|
212
|
+
applied = apply_import(plan)
|
|
213
|
+
result["importResult"] = applied
|
|
214
|
+
result["mutated"] = bool(applied["mutated"])
|
|
215
|
+
elif apply and plan["hasConflicts"]:
|
|
216
|
+
result["blocked"] = "Local target-definition conflicts require explicit resolution; no target was overwritten."
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _create_repository(login: str) -> None:
|
|
221
|
+
full_name = f"{login}/{PROFILE_REPOSITORY}"
|
|
222
|
+
result = _run_gh(
|
|
223
|
+
[
|
|
224
|
+
"repo",
|
|
225
|
+
"create",
|
|
226
|
+
full_name,
|
|
227
|
+
"--private",
|
|
228
|
+
"--description",
|
|
229
|
+
"Portable non-secret Project Forge developer environment definitions",
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
if result.returncode != 0:
|
|
233
|
+
raise DeploymentKitError(f"Cannot create personal Project Forge profile repository: {_detail(result)}")
|
|
234
|
+
status = repository_status()
|
|
235
|
+
if status["state"] != "ready" or not status["private"]:
|
|
236
|
+
raise DeploymentKitError("Personal Project Forge profile repository creation returned without a verified private repository.")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _put_remote_profile(repository: str, bundle: dict[str, Any], *, current_sha: str | None) -> str:
|
|
240
|
+
normalized = validate_bundle(bundle)
|
|
241
|
+
encoded = base64.b64encode((json.dumps(normalized, indent=2) + "\n").encode("utf-8")).decode("ascii")
|
|
242
|
+
args = [
|
|
243
|
+
"api",
|
|
244
|
+
"--method",
|
|
245
|
+
"PUT",
|
|
246
|
+
f"repos/{repository}/contents/{PROFILE_FILE}",
|
|
247
|
+
"-f",
|
|
248
|
+
"message=Update personal Project Forge profile",
|
|
249
|
+
"-f",
|
|
250
|
+
f"content={encoded}",
|
|
251
|
+
]
|
|
252
|
+
if current_sha:
|
|
253
|
+
args.extend(["-f", f"sha={current_sha}"])
|
|
254
|
+
result = _run_gh(args)
|
|
255
|
+
if result.returncode != 0:
|
|
256
|
+
raise DeploymentKitError(f"Cannot publish personal Project Forge profile: {_detail(result)}")
|
|
257
|
+
try:
|
|
258
|
+
value = json.loads(result.stdout)
|
|
259
|
+
sha = value["content"]["sha"]
|
|
260
|
+
except (KeyError, TypeError, json.JSONDecodeError) as error:
|
|
261
|
+
raise DeploymentKitError("GitHub returned malformed profile publication evidence.") from error
|
|
262
|
+
if not isinstance(sha, str) or not sha:
|
|
263
|
+
raise DeploymentKitError("GitHub profile publication omitted content SHA.")
|
|
264
|
+
return sha
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def ensure_profile_repository() -> dict[str, Any]:
|
|
268
|
+
status = repository_status()
|
|
269
|
+
created = False
|
|
270
|
+
if status["state"] == "missing":
|
|
271
|
+
_create_repository(status["login"])
|
|
272
|
+
created = True
|
|
273
|
+
status = repository_status()
|
|
274
|
+
remote = fetch_remote_profile()
|
|
275
|
+
if remote["sha"] is None:
|
|
276
|
+
local = export_bundle(None)
|
|
277
|
+
sha = _put_remote_profile(status["repository"], local, current_sha=None)
|
|
278
|
+
cache = cache_valid_profile(local, source="github", remote_sha=sha)
|
|
279
|
+
return {
|
|
280
|
+
"repository": status["repository"],
|
|
281
|
+
"createdRepository": created,
|
|
282
|
+
"publishedInitialProfile": True,
|
|
283
|
+
"targetCount": len(local["targets"]),
|
|
284
|
+
"cache": cache,
|
|
285
|
+
"mutated": True,
|
|
286
|
+
}
|
|
287
|
+
cache = cache_valid_profile(remote["bundle"], source="github", remote_sha=remote["sha"])
|
|
288
|
+
return {
|
|
289
|
+
"repository": status["repository"],
|
|
290
|
+
"createdRepository": created,
|
|
291
|
+
"publishedInitialProfile": False,
|
|
292
|
+
"targetCount": len(remote["bundle"]["targets"]),
|
|
293
|
+
"cache": cache,
|
|
294
|
+
"mutated": created,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _merge_non_conflicting(remote: dict[str, Any], local: dict[str, Any]) -> dict[str, Any]:
|
|
299
|
+
remote = validate_bundle(remote)
|
|
300
|
+
local = validate_bundle(local)
|
|
301
|
+
by_name = {item["targetName"]: item for item in remote["targets"]}
|
|
302
|
+
conflicts: list[str] = []
|
|
303
|
+
for item in local["targets"]:
|
|
304
|
+
current = by_name.get(item["targetName"])
|
|
305
|
+
if current is not None and current != item:
|
|
306
|
+
conflicts.append(item["targetName"])
|
|
307
|
+
continue
|
|
308
|
+
by_name[item["targetName"]] = item
|
|
309
|
+
if conflicts:
|
|
310
|
+
raise DeploymentKitError(
|
|
311
|
+
"Personal profile publish found conflicting existing definitions: "
|
|
312
|
+
+ ", ".join(sorted(conflicts))
|
|
313
|
+
+ ". Resolve explicitly; remote definitions were not overwritten."
|
|
314
|
+
)
|
|
315
|
+
return validate_bundle(
|
|
316
|
+
{
|
|
317
|
+
"schemaVersion": BUNDLE_SCHEMA_VERSION,
|
|
318
|
+
"kind": BUNDLE_KIND,
|
|
319
|
+
"targets": [by_name[name] for name in sorted(by_name)],
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def publish_profile() -> dict[str, Any]:
|
|
325
|
+
status = repository_status()
|
|
326
|
+
if status["state"] != "ready":
|
|
327
|
+
raise DeploymentKitError("Create/ensure the private personal Project Forge profile repository before publishing.")
|
|
328
|
+
remote = fetch_remote_profile()
|
|
329
|
+
local = export_bundle(None)
|
|
330
|
+
merged = _merge_non_conflicting(remote["bundle"], local)
|
|
331
|
+
if merged == remote["bundle"]:
|
|
332
|
+
cache = cache_valid_profile(merged, source="github", remote_sha=remote["sha"])
|
|
333
|
+
return {
|
|
334
|
+
"repository": status["repository"],
|
|
335
|
+
"status": "already-current",
|
|
336
|
+
"targetCount": len(merged["targets"]),
|
|
337
|
+
"cache": cache,
|
|
338
|
+
"mutated": False,
|
|
339
|
+
}
|
|
340
|
+
sha = _put_remote_profile(status["repository"], merged, current_sha=remote["sha"])
|
|
341
|
+
cache = cache_valid_profile(merged, source="github", remote_sha=sha)
|
|
342
|
+
return {
|
|
343
|
+
"repository": status["repository"],
|
|
344
|
+
"status": "published",
|
|
345
|
+
"targetCount": len(merged["targets"]),
|
|
346
|
+
"cache": cache,
|
|
347
|
+
"mutated": True,
|
|
348
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""User-scoped logical target profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .errors import DeploymentKitError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PROFILE_SCHEMA_VERSION = 1
|
|
16
|
+
TARGET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
17
|
+
ALIAS_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
18
|
+
USER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,31}$")
|
|
19
|
+
HOST_LABEL_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
|
|
20
|
+
SUPPORTED_TARGET_OS = {"ubuntu"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
24
|
+
value: dict[str, Any] = {}
|
|
25
|
+
for key, item in pairs:
|
|
26
|
+
if key in value:
|
|
27
|
+
raise DeploymentKitError(f"Duplicate JSON property: {key}")
|
|
28
|
+
value[key] = item
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def config_root(home: Path | None = None) -> Path:
|
|
33
|
+
override = os.environ.get("PROJECT_FORGE_HOME")
|
|
34
|
+
if override:
|
|
35
|
+
candidate = Path(override).expanduser()
|
|
36
|
+
if not candidate.is_absolute():
|
|
37
|
+
raise DeploymentKitError("PROJECT_FORGE_HOME must be an absolute path.")
|
|
38
|
+
return candidate.resolve()
|
|
39
|
+
return ((home or Path.home()).resolve() / ".project-forge").resolve()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_target_name(name: str) -> str:
|
|
43
|
+
if not isinstance(name, str) or TARGET_NAME_RE.fullmatch(name) is None:
|
|
44
|
+
raise DeploymentKitError(
|
|
45
|
+
"Target name must use lowercase letters, digits, and hyphens, and must start with a letter or digit."
|
|
46
|
+
)
|
|
47
|
+
return name
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def target_profile_path(name: str, home: Path | None = None) -> Path:
|
|
51
|
+
validate_target_name(name)
|
|
52
|
+
return config_root(home) / "targets" / f"{name}.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_host_name(value: Any) -> str:
|
|
56
|
+
if not isinstance(value, str) or not value or len(value) > 253:
|
|
57
|
+
raise DeploymentKitError("hostName must be a DNS/mDNS hostname.")
|
|
58
|
+
if value.endswith("."):
|
|
59
|
+
value = value[:-1]
|
|
60
|
+
try:
|
|
61
|
+
ipaddress.ip_address(value)
|
|
62
|
+
except ValueError:
|
|
63
|
+
pass
|
|
64
|
+
else:
|
|
65
|
+
raise DeploymentKitError(
|
|
66
|
+
"hostName must be a stable DNS/mDNS name, not an IP address; use addressOverride for a verified temporary IP."
|
|
67
|
+
)
|
|
68
|
+
labels = value.split(".")
|
|
69
|
+
if any(HOST_LABEL_RE.fullmatch(label) is None for label in labels):
|
|
70
|
+
raise DeploymentKitError("hostName must be a valid DNS/mDNS hostname.")
|
|
71
|
+
return value.lower()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ssh_path(value: Any, field: str, home: Path) -> Path:
|
|
75
|
+
if not isinstance(value, str) or not value.strip():
|
|
76
|
+
raise DeploymentKitError(f"{field} must not be empty.")
|
|
77
|
+
if value == "~":
|
|
78
|
+
candidate = home
|
|
79
|
+
elif value.startswith("~/") or value.startswith("~\\"):
|
|
80
|
+
candidate = home / value[2:]
|
|
81
|
+
else:
|
|
82
|
+
candidate = Path(value)
|
|
83
|
+
if not candidate.is_absolute():
|
|
84
|
+
raise DeploymentKitError(f"{field} must be absolute or home-relative with '~'.")
|
|
85
|
+
resolved = candidate.resolve()
|
|
86
|
+
ssh_dir = (home / ".ssh").resolve()
|
|
87
|
+
if resolved == ssh_dir or not resolved.is_relative_to(ssh_dir):
|
|
88
|
+
raise DeploymentKitError(f"{field} must be a file inside the user's SSH directory: {ssh_dir}")
|
|
89
|
+
return resolved
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_profile(
|
|
93
|
+
value: dict[str, Any],
|
|
94
|
+
*,
|
|
95
|
+
expected_target_name: str | None = None,
|
|
96
|
+
home: Path | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
expected_fields = {
|
|
99
|
+
"schemaVersion",
|
|
100
|
+
"targetName",
|
|
101
|
+
"targetOs",
|
|
102
|
+
"hostName",
|
|
103
|
+
"sshAlias",
|
|
104
|
+
"user",
|
|
105
|
+
"port",
|
|
106
|
+
"identityFile",
|
|
107
|
+
"knownHostsFile",
|
|
108
|
+
"addressOverride",
|
|
109
|
+
}
|
|
110
|
+
if not isinstance(value, dict) or set(value) != expected_fields:
|
|
111
|
+
raise DeploymentKitError(
|
|
112
|
+
"Target profile must contain exactly: " + ", ".join(sorted(expected_fields)) + "."
|
|
113
|
+
)
|
|
114
|
+
if type(value["schemaVersion"]) is not int or value["schemaVersion"] != PROFILE_SCHEMA_VERSION:
|
|
115
|
+
raise DeploymentKitError("Unsupported target profile schemaVersion.")
|
|
116
|
+
|
|
117
|
+
target_name = validate_target_name(value["targetName"])
|
|
118
|
+
if expected_target_name is not None and target_name != expected_target_name:
|
|
119
|
+
raise DeploymentKitError(
|
|
120
|
+
f"Target profile mismatch: expected '{expected_target_name}', got '{target_name}'."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
target_os = value["targetOs"]
|
|
124
|
+
if target_os not in SUPPORTED_TARGET_OS:
|
|
125
|
+
raise DeploymentKitError(
|
|
126
|
+
f"Unsupported targetOs '{target_os}'. Initial support is limited to: {', '.join(sorted(SUPPORTED_TARGET_OS))}."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
host_name = _validate_host_name(value["hostName"])
|
|
130
|
+
alias = value["sshAlias"]
|
|
131
|
+
if not isinstance(alias, str) or ALIAS_RE.fullmatch(alias) is None:
|
|
132
|
+
raise DeploymentKitError("sshAlias contains unsupported characters.")
|
|
133
|
+
|
|
134
|
+
username = value["user"]
|
|
135
|
+
if not isinstance(username, str) or USER_RE.fullmatch(username) is None:
|
|
136
|
+
raise DeploymentKitError("user contains unsupported characters.")
|
|
137
|
+
|
|
138
|
+
port = value["port"]
|
|
139
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
140
|
+
raise DeploymentKitError("port must be an integer from 1 through 65535.")
|
|
141
|
+
|
|
142
|
+
user_home = (home or Path.home()).resolve()
|
|
143
|
+
identity_file = _ssh_path(value["identityFile"], "identityFile", user_home)
|
|
144
|
+
known_hosts_file = _ssh_path(value["knownHostsFile"], "knownHostsFile", user_home)
|
|
145
|
+
if identity_file == known_hosts_file:
|
|
146
|
+
raise DeploymentKitError("identityFile and knownHostsFile must be different paths.")
|
|
147
|
+
|
|
148
|
+
address_override = value["addressOverride"]
|
|
149
|
+
if address_override is not None:
|
|
150
|
+
if not isinstance(address_override, str):
|
|
151
|
+
raise DeploymentKitError("addressOverride must be null or an IP address.")
|
|
152
|
+
try:
|
|
153
|
+
parsed = ipaddress.ip_address(address_override)
|
|
154
|
+
except ValueError as error:
|
|
155
|
+
raise DeploymentKitError("addressOverride must be a valid IPv4 or IPv6 address.") from error
|
|
156
|
+
if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
|
|
157
|
+
raise DeploymentKitError("addressOverride must not include an IPv6 scope identifier.")
|
|
158
|
+
address_override = str(parsed)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"schemaVersion": PROFILE_SCHEMA_VERSION,
|
|
162
|
+
"targetName": target_name,
|
|
163
|
+
"targetOs": target_os,
|
|
164
|
+
"hostName": host_name,
|
|
165
|
+
"sshAlias": alias,
|
|
166
|
+
"user": username,
|
|
167
|
+
"port": port,
|
|
168
|
+
"identityFile": str(identity_file),
|
|
169
|
+
"knownHostsFile": str(known_hosts_file),
|
|
170
|
+
"addressOverride": address_override,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def load_profile(name: str, *, home: Path | None = None) -> dict[str, Any]:
|
|
175
|
+
path = target_profile_path(name, home)
|
|
176
|
+
if path.is_symlink() or path.parent.is_symlink():
|
|
177
|
+
raise DeploymentKitError("Target profile and its containing directory must not be symbolic links.")
|
|
178
|
+
if not path.is_file():
|
|
179
|
+
raise DeploymentKitError(f"Target profile is missing: {path}")
|
|
180
|
+
try:
|
|
181
|
+
with path.open(encoding="utf-8") as source:
|
|
182
|
+
value = json.load(source, object_pairs_hook=_unique_object)
|
|
183
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
184
|
+
raise DeploymentKitError(f"Cannot read target profile {path}: {error}") from error
|
|
185
|
+
return validate_profile(value, expected_target_name=name, home=home)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def save_profile(value: dict[str, Any], *, home: Path | None = None) -> Path:
|
|
189
|
+
normalized = validate_profile(value, home=home)
|
|
190
|
+
path = target_profile_path(normalized["targetName"], home)
|
|
191
|
+
directory = path.parent
|
|
192
|
+
if directory.exists() and (directory.is_symlink() or not directory.is_dir()):
|
|
193
|
+
raise DeploymentKitError(f"Target profile directory is not a real directory: {directory}")
|
|
194
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
195
|
+
if os.name != "nt":
|
|
196
|
+
directory.chmod(0o700)
|
|
197
|
+
if path.exists() and path.is_symlink():
|
|
198
|
+
raise DeploymentKitError(f"Refusing to replace symbolic-link target profile: {path}")
|
|
199
|
+
temporary = path.with_suffix(".tmp")
|
|
200
|
+
temporary.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
|
|
201
|
+
if os.name != "nt":
|
|
202
|
+
temporary.chmod(0o600)
|
|
203
|
+
temporary.replace(path)
|
|
204
|
+
return path
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Read-only exact-image registry access verification for logical targets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import DeploymentKitError
|
|
11
|
+
from .profiles import load_profile
|
|
12
|
+
from .ssh import ssh_args, verify_access
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$")
|
|
16
|
+
|
|
17
|
+
_REMOTE_SCRIPT = r'''#!/usr/bin/env bash
|
|
18
|
+
set -Eeuo pipefail
|
|
19
|
+
|
|
20
|
+
unset DOCKER_CONFIG
|
|
21
|
+
|
|
22
|
+
IMAGE="$1"
|
|
23
|
+
|
|
24
|
+
[[ "$IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._/:+-]*@sha256:[0-9a-f]{64}$ ]] || {
|
|
25
|
+
echo "Image must be an immutable sha256 digest reference." >&2
|
|
26
|
+
exit 41
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
command -v docker >/dev/null 2>&1 || {
|
|
30
|
+
echo "Docker CLI is unavailable on the target." >&2
|
|
31
|
+
exit 42
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
config_dir="$HOME/.docker"
|
|
35
|
+
config_file="$config_dir/config.json"
|
|
36
|
+
credential_state="absent"
|
|
37
|
+
|
|
38
|
+
if [[ -e "$config_file" ]]; then
|
|
39
|
+
[[ -d "$config_dir" && ! -L "$config_dir" && -f "$config_file" && ! -L "$config_file" ]] || {
|
|
40
|
+
echo "Docker credential path is not a regular protected directory/file." >&2
|
|
41
|
+
exit 43
|
|
42
|
+
}
|
|
43
|
+
dir_mode="$(stat -c '%a' "$config_dir")"
|
|
44
|
+
file_mode="$(stat -c '%a' "$config_file")"
|
|
45
|
+
current_uid="$(id -u)"
|
|
46
|
+
if (( (8#$dir_mode & 077) != 0 || (8#$file_mode & 077) != 0 )) ||
|
|
47
|
+
[[ "$(stat -c '%u' "$config_dir")" != "$current_uid" || "$(stat -c '%u' "$config_file")" != "$current_uid" ]]; then
|
|
48
|
+
echo "Docker credentials must be owned by the deployment account and deny group/other access." >&2
|
|
49
|
+
exit 43
|
|
50
|
+
fi
|
|
51
|
+
export DOCKER_CONFIG="$config_dir"
|
|
52
|
+
credential_state="protected"
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
if ! docker manifest inspect "$IMAGE" </dev/null >/dev/null 2>&1; then
|
|
56
|
+
echo "Registry manifest access failed for the exact image." >&2
|
|
57
|
+
exit 44
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
printf 'ACCESS\t%s\n' "$credential_state"
|
|
61
|
+
'''
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_registry_image(value: str) -> str:
|
|
65
|
+
if not isinstance(value, str) or _IMAGE_RE.fullmatch(value) is None:
|
|
66
|
+
raise DeploymentKitError(
|
|
67
|
+
"image must be an immutable image reference ending in @sha256:<64 lowercase hex>."
|
|
68
|
+
)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_remote_script() -> str:
|
|
73
|
+
return _REMOTE_SCRIPT
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_access(output: str) -> str:
|
|
77
|
+
lines = [line for line in output.splitlines() if line]
|
|
78
|
+
if len(lines) != 1:
|
|
79
|
+
raise DeploymentKitError("Malformed registry access verification output.")
|
|
80
|
+
parts = lines[0].split("\t")
|
|
81
|
+
if len(parts) != 2 or parts[0] != "ACCESS" or parts[1] not in {"absent", "protected"}:
|
|
82
|
+
raise DeploymentKitError("Malformed registry access verification output.")
|
|
83
|
+
return parts[1]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def verify_registry_access(name: str, image: str) -> dict[str, Any]:
|
|
87
|
+
exact_image = normalize_registry_image(image)
|
|
88
|
+
profile = load_profile(name)
|
|
89
|
+
access = verify_access(profile)
|
|
90
|
+
command = ssh_args(
|
|
91
|
+
profile,
|
|
92
|
+
access["address"],
|
|
93
|
+
command="bash -s -- " + shlex.quote(exact_image),
|
|
94
|
+
)
|
|
95
|
+
completed = subprocess.run(
|
|
96
|
+
command,
|
|
97
|
+
input=build_remote_script().encode("utf-8"),
|
|
98
|
+
check=False,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
)
|
|
101
|
+
stdout = completed.stdout.decode("utf-8", errors="replace")
|
|
102
|
+
stderr = completed.stderr.decode("utf-8", errors="replace")
|
|
103
|
+
if completed.returncode != 0:
|
|
104
|
+
detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
|
|
105
|
+
raise DeploymentKitError(f"Read-only registry access verification failed: {detail}")
|
|
106
|
+
|
|
107
|
+
credential_state = _parse_access(stdout)
|
|
108
|
+
return {
|
|
109
|
+
"targetName": name,
|
|
110
|
+
"image": exact_image,
|
|
111
|
+
"manifestAccess": "verified",
|
|
112
|
+
"credentialConfig": credential_state,
|
|
113
|
+
"mutated": False,
|
|
114
|
+
}
|