@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.
Files changed (117) hide show
  1. package/README.md +134 -2
  2. package/assets/components.json +33 -0
  3. package/assets/core/distribution.json +66 -0
  4. package/assets/deployment/distribution.json +54 -0
  5. package/assets/generated/ASSEMBLY.json +557 -0
  6. package/assets/generated/core/core/agents/coach.agent.md +69 -0
  7. package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
  8. package/assets/generated/core/core/instructions/project-forge-docs.instructions.md +45 -0
  9. package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +79 -0
  10. package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
  11. package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +59 -0
  12. package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +108 -0
  13. package/assets/generated/core/core/learning/LEARNING_MAP.md +108 -0
  14. package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +39 -0
  15. package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
  16. package/assets/generated/core/core/learning/SQL_DRILLS.md +90 -0
  17. package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +301 -0
  18. package/assets/generated/core/core/process/COACHING_GUIDE.md +92 -0
  19. package/assets/generated/core/core/process/DATABASE_LEARNING_WORKBENCH.md +397 -0
  20. package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +166 -0
  21. package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
  22. package/assets/generated/core/core/process/HUMAN_GATES.md +83 -0
  23. package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +64 -0
  24. package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +326 -0
  25. package/assets/generated/core/core/process/PROJECT_FORGE_ENGINEERING_GUIDE.md +308 -0
  26. package/assets/generated/core/core/process/PR_WORKFLOW.md +55 -0
  27. package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
  28. package/assets/generated/core/core/process/SYSTEM_DESIGN_LEARNING_WORKBENCH.md +520 -0
  29. package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
  30. package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
  31. package/assets/generated/core/core/skills/coach/SKILL.md +174 -0
  32. package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
  33. package/assets/generated/core/core/skills/core-sync/SKILL.md +178 -0
  34. package/assets/generated/core/core/skills/db-lab/SKILL.md +245 -0
  35. package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
  36. package/assets/generated/core/core/skills/deliver/SKILL.md +397 -0
  37. package/assets/generated/core/core/skills/deploy/SKILL.md +188 -0
  38. package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +234 -0
  39. package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +231 -0
  40. package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
  41. package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
  42. package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
  43. package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
  44. package/assets/generated/core/core/skills/next-step/SKILL.md +221 -0
  45. package/assets/generated/core/core/skills/progress/SKILL.md +207 -0
  46. package/assets/generated/core/core/skills/project-forge-update/SKILL.md +228 -0
  47. package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
  48. package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
  49. package/assets/generated/core/core/skills/setup/SKILL.md +362 -0
  50. package/assets/generated/core/core/skills/start-work/SKILL.md +190 -0
  51. package/assets/generated/core/core/skills/system-lab/SKILL.md +333 -0
  52. package/assets/generated/core/core/skills/tear-down/SKILL.md +140 -0
  53. package/assets/generated/core/core/skills/upstream-fix/SKILL.md +221 -0
  54. package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
  55. package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
  56. package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
  57. package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
  58. package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
  59. package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
  60. package/assets/generated/core/profiles/greenfield/project-forge-workspace.json +12 -0
  61. package/assets/generated/core/tools/db_lab/sqlite_workbench.py +463 -0
  62. package/assets/generated/core/tools/docs_site/docs_site.py +512 -0
  63. package/assets/generated/core/tools/package/check_baseline.py +102 -0
  64. package/assets/generated/core/tools/package/check_upstream.py +171 -0
  65. package/assets/generated/core/tools/system_lab/system_workbench.py +243 -0
  66. package/assets/generated/deployment/distribution/project-forge-deploy.py +8 -0
  67. package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +335 -0
  68. package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +95 -0
  69. package/assets/generated/deployment/docs/WORKSPACE_INSTANCES.md +154 -0
  70. package/assets/generated/deployment/docs/images/developer-environment-journey.svg +56 -0
  71. package/assets/generated/deployment/docs/images/personal-environment-architecture.svg +54 -0
  72. package/assets/generated/deployment/docs/images/poc-environment-model.svg +61 -0
  73. package/assets/generated/deployment/src/project_forge_deployment/__init__.py +3 -0
  74. package/assets/generated/deployment/src/project_forge_deployment/__main__.py +3 -0
  75. package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +163 -0
  76. package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +455 -0
  77. package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +416 -0
  78. package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +136 -0
  79. package/assets/generated/deployment/src/project_forge_deployment/box_platform.py +208 -0
  80. package/assets/generated/deployment/src/project_forge_deployment/box_platform_assets.py +100 -0
  81. package/assets/generated/deployment/src/project_forge_deployment/box_platform_remote.py +271 -0
  82. package/assets/generated/deployment/src/project_forge_deployment/cli.py +437 -0
  83. package/assets/generated/deployment/src/project_forge_deployment/doctor.py +121 -0
  84. package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +37 -0
  85. package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +271 -0
  86. package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +228 -0
  87. package/assets/generated/deployment/src/project_forge_deployment/errors.py +5 -0
  88. package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +328 -0
  89. package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +105 -0
  90. package/assets/generated/deployment/src/project_forge_deployment/keys.py +171 -0
  91. package/assets/generated/deployment/src/project_forge_deployment/local_cli.py +189 -0
  92. package/assets/generated/deployment/src/project_forge_deployment/local_runtime.py +526 -0
  93. package/assets/generated/deployment/src/project_forge_deployment/local_spec.py +82 -0
  94. package/assets/generated/deployment/src/project_forge_deployment/local_sqlite.py +205 -0
  95. package/assets/generated/deployment/src/project_forge_deployment/portable_profile.py +181 -0
  96. package/assets/generated/deployment/src/project_forge_deployment/profile_migration.py +70 -0
  97. package/assets/generated/deployment/src/project_forge_deployment/profile_repository.py +348 -0
  98. package/assets/generated/deployment/src/project_forge_deployment/profiles.py +204 -0
  99. package/assets/generated/deployment/src/project_forge_deployment/registry_access.py +114 -0
  100. package/assets/generated/deployment/src/project_forge_deployment/registry_cli.py +51 -0
  101. package/assets/generated/deployment/src/project_forge_deployment/route_registry.py +367 -0
  102. package/assets/generated/deployment/src/project_forge_deployment/route_registry_remote.py +443 -0
  103. package/assets/generated/deployment/src/project_forge_deployment/security.py +290 -0
  104. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +335 -0
  105. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +340 -0
  106. package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +86 -0
  107. package/assets/generated/deployment/src/project_forge_deployment/sqlite_import_recovery.py +437 -0
  108. package/assets/generated/deployment/src/project_forge_deployment/ssh.py +119 -0
  109. package/assets/generated/deployment/src/project_forge_deployment/teardown.py +300 -0
  110. package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +139 -0
  111. package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +100 -0
  112. package/assets/generated/deployment/src/project_forge_deployment/trust.py +278 -0
  113. package/bin/project-forge.mjs +9 -0
  114. package/package.json +31 -4
  115. package/src/cli.mjs +150 -0
  116. package/src/engine.mjs +243 -0
  117. package/src/package-data.mjs +18 -0
@@ -0,0 +1,328 @@
1
+ """Password-assisted Home Lab target onboarding with local-only credential prompting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import shlex
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .errors import DeploymentKitError
13
+ from .keys import key_fingerprint, public_key_info, required_tool as required_ssh_tool
14
+ from .portable_profile import _machine_paths
15
+ from .profiles import load_profile, save_profile, target_profile_path, validate_profile, validate_target_name
16
+ from .security import check_identity_security, prepare_ssh_directory, validate_target_trust_file
17
+ from .ssh import verify_access
18
+
19
+
20
+ HOME_LAB_BOOTSTRAP_PHRASE = "[HOME LAB ACCESS BOOTSTRAP]"
21
+
22
+
23
+ def _create_unencrypted_key(profile: dict[str, Any]) -> dict[str, Any]:
24
+ identity = Path(profile["identityFile"])
25
+ public = Path(f"{identity}.pub")
26
+ if identity.exists() or public.exists():
27
+ raise DeploymentKitError(
28
+ f"Refusing to overwrite existing SSH key material: {identity} / {public}"
29
+ )
30
+ prepare_ssh_directory(identity.parent)
31
+ comment = f"project-forge:{profile['targetName']}:{profile['sshAlias']}"
32
+ result = subprocess.run(
33
+ [
34
+ required_ssh_tool("ssh-keygen"),
35
+ "-q",
36
+ "-t",
37
+ "ed25519",
38
+ "-a",
39
+ "64",
40
+ "-N",
41
+ "",
42
+ "-f",
43
+ str(identity),
44
+ "-C",
45
+ comment,
46
+ ],
47
+ check=False,
48
+ )
49
+ if result.returncode != 0:
50
+ raise DeploymentKitError(
51
+ "ssh-keygen did not complete successfully. Any partial key material is left for explicit inspection."
52
+ )
53
+ if not identity.is_file() or identity.is_symlink() or not public.is_file() or public.is_symlink():
54
+ raise DeploymentKitError("ssh-keygen completed without the expected private/public key pair.")
55
+ if os.name != "nt":
56
+ identity.chmod(0o600)
57
+ public.chmod(0o644)
58
+ check_identity_security(identity)
59
+ return {
60
+ "identityFile": str(identity),
61
+ "publicKeyFile": str(public),
62
+ "identityFingerprint": key_fingerprint(identity),
63
+ "passphrase": "none",
64
+ "mutated": True,
65
+ }
66
+
67
+
68
+ def _desired_profile(
69
+ name: str,
70
+ *,
71
+ host_name: str,
72
+ user: str,
73
+ port: int,
74
+ ssh_alias: str | None,
75
+ address: str | None,
76
+ ) -> dict[str, Any]:
77
+ identity, known_hosts = _machine_paths(validate_target_name(name))
78
+ return validate_profile(
79
+ {
80
+ "schemaVersion": 1,
81
+ "targetName": name,
82
+ "targetOs": "ubuntu",
83
+ "hostName": host_name,
84
+ "sshAlias": ssh_alias or name,
85
+ "user": user,
86
+ "port": port,
87
+ "identityFile": identity,
88
+ "knownHostsFile": known_hosts,
89
+ "addressOverride": address,
90
+ },
91
+ expected_target_name=name,
92
+ )
93
+
94
+
95
+ def _same_target_definition(left: dict[str, Any], right: dict[str, Any]) -> bool:
96
+ fields = ("targetName", "targetOs", "hostName", "sshAlias", "user", "port", "addressOverride")
97
+ return all(left[field] == right[field] for field in fields)
98
+
99
+
100
+ def plan_home_lab_bootstrap(
101
+ name: str,
102
+ *,
103
+ host_name: str,
104
+ user: str,
105
+ port: int = 22,
106
+ ssh_alias: str | None = None,
107
+ address: str | None = None,
108
+ protect_key_with_passphrase: bool = False,
109
+ ) -> dict[str, Any]:
110
+ desired = _desired_profile(
111
+ name,
112
+ host_name=host_name,
113
+ user=user,
114
+ port=port,
115
+ ssh_alias=ssh_alias,
116
+ address=address,
117
+ )
118
+ path = target_profile_path(name)
119
+ profile_state = "create"
120
+ if path.exists():
121
+ current = load_profile(name)
122
+ if not _same_target_definition(current, desired):
123
+ raise DeploymentKitError(
124
+ "Existing target profile differs from the requested Home Lab definition. "
125
+ "Refusing to overwrite it; inspect/repair the profile explicitly."
126
+ )
127
+ desired = current
128
+ profile_state = "reuse"
129
+
130
+ identity = Path(desired["identityFile"])
131
+ public = Path(f"{identity}.pub")
132
+ if identity.exists() != public.exists():
133
+ raise DeploymentKitError("Existing SSH key pair is incomplete; repair it explicitly before onboarding.")
134
+ key_state = "reuse" if identity.exists() else "create"
135
+
136
+ known_hosts = Path(desired["knownHostsFile"])
137
+ trust_state = "reuse" if known_hosts.is_file() and known_hosts.stat().st_size > 0 else "accept-first-use"
138
+ if trust_state == "reuse":
139
+ validate_target_trust_file(desired)
140
+
141
+ return {
142
+ "targetName": name,
143
+ "securityProfile": "home-lab",
144
+ "profileState": profile_state,
145
+ "keyState": key_state,
146
+ "keyPassphraseMode": "interactive" if protect_key_with_passphrase else "none",
147
+ "trustState": trust_state,
148
+ "hostName": desired["hostName"],
149
+ "addressOverride": desired["addressOverride"],
150
+ "sshAlias": desired["sshAlias"],
151
+ "user": desired["user"],
152
+ "port": desired["port"],
153
+ "identityFile": desired["identityFile"],
154
+ "knownHostsFile": desired["knownHostsFile"],
155
+ "passwordHandling": "OpenSSH local terminal prompt only; never stored or passed as an argument",
156
+ "mutated": False,
157
+ }
158
+
159
+
160
+ def _create_key(profile: dict[str, Any], *, protect_key_with_passphrase: bool) -> dict[str, Any]:
161
+ if protect_key_with_passphrase:
162
+ # Existing helper intentionally lets ssh-keygen own the interactive passphrase prompt.
163
+ from .keys import create_dedicated_key
164
+
165
+ result = create_dedicated_key(profile)
166
+ return {**result, "passphrase": "interactive"}
167
+ return _create_unencrypted_key(profile)
168
+
169
+
170
+ def _password_bootstrap_args(profile: dict[str, Any], *, first_use: bool, command: str) -> list[str]:
171
+ target = profile["addressOverride"] or profile["hostName"]
172
+ return [
173
+ required_ssh_tool("ssh"),
174
+ "-F",
175
+ "none",
176
+ "-o",
177
+ "BatchMode=no",
178
+ "-o",
179
+ "ConnectTimeout=8",
180
+ "-o",
181
+ f"StrictHostKeyChecking={'accept-new' if first_use else 'yes'}",
182
+ "-o",
183
+ "UpdateHostKeys=no",
184
+ "-o",
185
+ "CheckHostIP=no",
186
+ "-o",
187
+ "VerifyHostKeyDNS=no",
188
+ "-o",
189
+ "GlobalKnownHostsFile=none",
190
+ "-o",
191
+ f"UserKnownHostsFile={profile['knownHostsFile']}",
192
+ "-o",
193
+ f"HostKeyAlias={profile['sshAlias']}",
194
+ "-o",
195
+ "PubkeyAuthentication=no",
196
+ "-o",
197
+ "PasswordAuthentication=yes",
198
+ "-o",
199
+ "KbdInteractiveAuthentication=yes",
200
+ "-o",
201
+ "PreferredAuthentications=keyboard-interactive,password",
202
+ "-o",
203
+ "ForwardAgent=no",
204
+ "-o",
205
+ "ForwardX11=no",
206
+ "-o",
207
+ "ClearAllForwardings=yes",
208
+ "-p",
209
+ str(profile["port"]),
210
+ f"{profile['user']}@{target}",
211
+ command,
212
+ ]
213
+
214
+
215
+ def _install_public_key_with_password(profile: dict[str, Any], *, first_use: bool) -> None:
216
+ info = public_key_info(profile)
217
+ encoded = base64.b64encode(info["publicKey"].encode("utf-8")).decode("ascii")
218
+ script = (
219
+ "set -eu; umask 077; mkdir -p \"$HOME/.ssh\"; chmod 700 \"$HOME/.ssh\"; "
220
+ "auth=\"$HOME/.ssh/authorized_keys\"; touch \"$auth\"; chmod 600 \"$auth\"; "
221
+ f"key=$(printf '%s' {shlex.quote(encoded)} | base64 --decode); "
222
+ "grep -Fxq -- \"$key\" \"$auth\" || printf '%s\\n' \"$key\" >> \"$auth\""
223
+ )
224
+ known_hosts = Path(profile["knownHostsFile"])
225
+ prepare_ssh_directory(known_hosts.parent)
226
+ print(
227
+ f"OpenSSH will prompt locally for the password of {profile['user']}@{profile['hostName']}. "
228
+ "The password is not read or stored by Project Forge."
229
+ )
230
+ result = subprocess.run(
231
+ _password_bootstrap_args(
232
+ profile,
233
+ first_use=first_use,
234
+ command="bash -c " + shlex.quote(script),
235
+ ),
236
+ check=False,
237
+ )
238
+ if result.returncode != 0:
239
+ raise DeploymentKitError(
240
+ "Password-assisted SSH bootstrap did not complete successfully. "
241
+ "No password was retained; inspect target access and retry."
242
+ )
243
+
244
+
245
+ def apply_home_lab_bootstrap(
246
+ plan: dict[str, Any],
247
+ *,
248
+ protect_key_with_passphrase: bool = False,
249
+ ) -> dict[str, Any]:
250
+ required = {
251
+ "targetName",
252
+ "securityProfile",
253
+ "profileState",
254
+ "keyState",
255
+ "keyPassphraseMode",
256
+ "trustState",
257
+ "hostName",
258
+ "addressOverride",
259
+ "sshAlias",
260
+ "user",
261
+ "port",
262
+ "identityFile",
263
+ "knownHostsFile",
264
+ "passwordHandling",
265
+ "mutated",
266
+ }
267
+ if not isinstance(plan, dict) or set(plan) != required or plan["securityProfile"] != "home-lab":
268
+ raise DeploymentKitError("Malformed Home Lab bootstrap plan.")
269
+
270
+ desired = _desired_profile(
271
+ plan["targetName"],
272
+ host_name=plan["hostName"],
273
+ user=plan["user"],
274
+ port=plan["port"],
275
+ ssh_alias=plan["sshAlias"],
276
+ address=plan["addressOverride"],
277
+ )
278
+ path = target_profile_path(plan["targetName"])
279
+ mutated = False
280
+ if plan["profileState"] == "create":
281
+ if path.exists():
282
+ raise DeploymentKitError("Target profile appeared after planning; create a fresh onboarding plan.")
283
+ save_profile(desired)
284
+ mutated = True
285
+ else:
286
+ current = load_profile(plan["targetName"])
287
+ if not _same_target_definition(current, desired):
288
+ raise DeploymentKitError("Target profile changed after planning; create a fresh onboarding plan.")
289
+ desired = current
290
+
291
+ identity = Path(desired["identityFile"])
292
+ public = Path(f"{identity}.pub")
293
+ if plan["keyState"] == "create":
294
+ if identity.exists() or public.exists():
295
+ raise DeploymentKitError("SSH key material appeared after planning; create a fresh onboarding plan.")
296
+ _create_key(desired, protect_key_with_passphrase=protect_key_with_passphrase)
297
+ mutated = True
298
+ elif not identity.is_file() or not public.is_file():
299
+ raise DeploymentKitError("Expected existing SSH key pair is no longer present.")
300
+
301
+ known_hosts = Path(desired["knownHostsFile"])
302
+ first_use = plan["trustState"] == "accept-first-use"
303
+ if not first_use:
304
+ validate_target_trust_file(desired)
305
+
306
+ # If the complete strict path is already healthy, do not ask for a password again.
307
+ if not first_use:
308
+ try:
309
+ evidence = verify_access(desired)
310
+ return {
311
+ "targetName": desired["targetName"],
312
+ "status": "already-ready",
313
+ "strictVerification": evidence,
314
+ "mutated": mutated,
315
+ }
316
+ except DeploymentKitError:
317
+ pass
318
+
319
+ _install_public_key_with_password(desired, first_use=first_use)
320
+ validate_target_trust_file(desired)
321
+ evidence = verify_access(desired)
322
+ return {
323
+ "targetName": desired["targetName"],
324
+ "status": "ready",
325
+ "strictVerification": evidence,
326
+ "passwordRetained": False,
327
+ "mutated": True,
328
+ }
@@ -0,0 +1,105 @@
1
+ """Transfer an exact local Docker image to a verified remote target without remote registry credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from .errors import DeploymentKitError
11
+ from .profiles import load_profile
12
+ from .registry_access import normalize_registry_image
13
+ from .ssh import ssh_args, verify_access
14
+
15
+
16
+ def _docker() -> str:
17
+ path = shutil.which("docker")
18
+ if path is None:
19
+ raise DeploymentKitError("Docker CLI is unavailable on the workstation.")
20
+ return path
21
+
22
+
23
+ def verify_local_image(image: str) -> dict[str, str]:
24
+ exact = normalize_registry_image(image)
25
+ result = subprocess.run(
26
+ [_docker(), "image", "inspect", exact],
27
+ check=False,
28
+ stdout=subprocess.DEVNULL,
29
+ stderr=subprocess.PIPE,
30
+ text=True,
31
+ encoding="utf-8",
32
+ errors="replace",
33
+ )
34
+ if result.returncode != 0:
35
+ raise DeploymentKitError(
36
+ f"Exact image is not present in the workstation Docker engine: {exact}. "
37
+ "Pull/build it locally before transfer."
38
+ )
39
+ return {"image": exact, "localImage": "verified"}
40
+
41
+
42
+ def _remote_has_image(profile: dict[str, Any], address: str, image: str) -> bool:
43
+ command = "sudo -n docker image inspect " + shlex.quote(image) + " >/dev/null 2>&1"
44
+ result = subprocess.run(
45
+ ssh_args(profile, address, command=command),
46
+ check=False,
47
+ stdout=subprocess.DEVNULL,
48
+ stderr=subprocess.DEVNULL,
49
+ )
50
+ return result.returncode == 0
51
+
52
+
53
+ def transfer_image(name: str, image: str) -> dict[str, Any]:
54
+ exact = normalize_registry_image(image)
55
+ verify_local_image(exact)
56
+ profile = load_profile(name)
57
+ access = verify_access(profile)
58
+ address = access["address"]
59
+
60
+ if _remote_has_image(profile, address, exact):
61
+ return {
62
+ "targetName": name,
63
+ "image": exact,
64
+ "status": "already-present",
65
+ "mutated": False,
66
+ }
67
+
68
+ save = subprocess.Popen(
69
+ [_docker(), "save", exact],
70
+ stdout=subprocess.PIPE,
71
+ stderr=subprocess.PIPE,
72
+ )
73
+ assert save.stdout is not None
74
+ load = subprocess.Popen(
75
+ ssh_args(profile, address, command="sudo -n docker load >/dev/null"),
76
+ stdin=save.stdout,
77
+ stdout=subprocess.PIPE,
78
+ stderr=subprocess.PIPE,
79
+ )
80
+ save.stdout.close()
81
+ load_stdout, load_stderr = load.communicate()
82
+ save_stderr = save.stderr.read() if save.stderr is not None else b""
83
+ save_code = save.wait()
84
+
85
+ if save_code != 0:
86
+ detail = save_stderr.decode("utf-8", errors="replace").strip() or f"exit {save_code}"
87
+ raise DeploymentKitError(f"Docker image export failed: {detail}")
88
+ if load.returncode != 0:
89
+ detail = (
90
+ load_stderr.decode("utf-8", errors="replace").strip()
91
+ or load_stdout.decode("utf-8", errors="replace").strip()
92
+ or f"exit {load.returncode}"
93
+ )
94
+ raise DeploymentKitError(f"Remote Docker image load failed: {detail}")
95
+
96
+ if not _remote_has_image(profile, address, exact):
97
+ raise DeploymentKitError(
98
+ "Remote docker load completed but the exact immutable image reference is not inspectable on the target."
99
+ )
100
+ return {
101
+ "targetName": name,
102
+ "image": exact,
103
+ "status": "transferred",
104
+ "mutated": True,
105
+ }
@@ -0,0 +1,171 @@
1
+ """Dedicated SSH-key and agent onboarding helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .errors import DeploymentKitError
13
+ from .security import check_identity_security, prepare_ssh_directory
14
+
15
+
16
+ FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
17
+
18
+
19
+ def required_tool(name: str) -> str:
20
+ path = shutil.which(name)
21
+ if path is None:
22
+ raise DeploymentKitError(f"Required OpenSSH tool is unavailable: {name}")
23
+ return path
24
+
25
+
26
+ def key_fingerprint(path: Path) -> str:
27
+ if not path.is_file() or path.is_symlink():
28
+ raise DeploymentKitError(f"SSH key file is missing or unsafe: {path}")
29
+ result = subprocess.run(
30
+ [required_tool("ssh-keygen"), "-lf", str(path)],
31
+ check=False,
32
+ capture_output=True,
33
+ text=True,
34
+ encoding="utf-8",
35
+ errors="replace",
36
+ )
37
+ if result.returncode != 0:
38
+ raise DeploymentKitError(f"Cannot read SSH key fingerprint for {path}.")
39
+ fingerprint = next(
40
+ (token for token in result.stdout.split() if FINGERPRINT_RE.fullmatch(token)),
41
+ None,
42
+ )
43
+ if fingerprint is None:
44
+ raise DeploymentKitError(f"ssh-keygen returned no valid SHA256 fingerprint for {path}.")
45
+ return fingerprint
46
+
47
+
48
+ def create_dedicated_key(profile: dict[str, Any]) -> dict[str, Any]:
49
+ identity = Path(profile["identityFile"])
50
+ public = Path(f"{identity}.pub")
51
+ if identity.exists() or public.exists():
52
+ raise DeploymentKitError(
53
+ f"Refusing to overwrite existing SSH key material: {identity} / {public}"
54
+ )
55
+
56
+ prepare_ssh_directory(identity.parent)
57
+ comment = f"project-forge:{profile['targetName']}:{profile['sshAlias']}"
58
+ result = subprocess.run(
59
+ [
60
+ required_tool("ssh-keygen"),
61
+ "-q",
62
+ "-t",
63
+ "ed25519",
64
+ "-a",
65
+ "64",
66
+ "-f",
67
+ str(identity),
68
+ "-C",
69
+ comment,
70
+ ],
71
+ check=False,
72
+ )
73
+ if result.returncode != 0:
74
+ raise DeploymentKitError(
75
+ "ssh-keygen did not complete successfully. Any partial key material is left in place for explicit inspection."
76
+ )
77
+ if not identity.is_file() or identity.is_symlink() or not public.is_file() or public.is_symlink():
78
+ raise DeploymentKitError("ssh-keygen completed without the expected private/public key pair.")
79
+
80
+ if os.name != "nt":
81
+ identity.chmod(0o600)
82
+ public.chmod(0o644)
83
+ check_identity_security(identity)
84
+ return {
85
+ "targetName": profile["targetName"],
86
+ "identityFile": str(identity),
87
+ "publicKeyFile": str(public),
88
+ "identityFingerprint": key_fingerprint(identity),
89
+ "status": "created",
90
+ "mutated": True,
91
+ }
92
+
93
+
94
+ def public_key_info(profile: dict[str, Any]) -> dict[str, str]:
95
+ identity = Path(profile["identityFile"])
96
+ public = Path(f"{identity}.pub")
97
+ check_identity_security(identity)
98
+ if not public.is_file() or public.is_symlink():
99
+ raise DeploymentKitError(f"Dedicated public key is missing or unsafe: {public}")
100
+ try:
101
+ lines = [line.strip() for line in public.read_text(encoding="utf-8").splitlines() if line.strip()]
102
+ except (OSError, UnicodeError) as error:
103
+ raise DeploymentKitError(f"Cannot read dedicated public key {public}: {error}") from error
104
+ if len(lines) != 1 or not lines[0].startswith("ssh-ed25519 "):
105
+ raise DeploymentKitError("Dedicated public-key file must contain exactly one ssh-ed25519 key.")
106
+ return {
107
+ "targetName": profile["targetName"],
108
+ "publicKeyFile": str(public),
109
+ "identityFingerprint": key_fingerprint(identity),
110
+ "publicKey": lines[0],
111
+ }
112
+
113
+
114
+ def agent_status(profile: dict[str, Any]) -> dict[str, Any]:
115
+ identity = Path(profile["identityFile"])
116
+ check_identity_security(identity)
117
+ fingerprint = key_fingerprint(identity)
118
+ result = subprocess.run(
119
+ [required_tool("ssh-add"), "-l"],
120
+ check=False,
121
+ capture_output=True,
122
+ text=True,
123
+ encoding="utf-8",
124
+ errors="replace",
125
+ )
126
+ if result.returncode == 2:
127
+ return {
128
+ "targetName": profile["targetName"],
129
+ "state": "agent-unavailable",
130
+ "identityFingerprint": fingerprint,
131
+ "loaded": False,
132
+ "mutated": False,
133
+ }
134
+ if result.returncode == 1:
135
+ return {
136
+ "targetName": profile["targetName"],
137
+ "state": "not-loaded",
138
+ "identityFingerprint": fingerprint,
139
+ "loaded": False,
140
+ "mutated": False,
141
+ }
142
+ if result.returncode != 0:
143
+ raise DeploymentKitError(f"ssh-add could not inspect the running SSH agent (exit {result.returncode}).")
144
+ loaded = fingerprint in result.stdout.split()
145
+ return {
146
+ "targetName": profile["targetName"],
147
+ "state": "loaded" if loaded else "not-loaded",
148
+ "identityFingerprint": fingerprint,
149
+ "loaded": loaded,
150
+ "mutated": False,
151
+ }
152
+
153
+
154
+ def add_key_to_agent(profile: dict[str, Any]) -> dict[str, Any]:
155
+ before = agent_status(profile)
156
+ if before["state"] == "agent-unavailable":
157
+ raise DeploymentKitError(
158
+ "The SSH agent is unavailable. Start/enable it as a separate workstation action; "
159
+ "the Deployment Kit will not change agent service configuration implicitly."
160
+ )
161
+ if before["loaded"]:
162
+ return {**before, "status": "already-loaded"}
163
+
164
+ identity = Path(profile["identityFile"])
165
+ result = subprocess.run([required_tool("ssh-add"), str(identity)], check=False)
166
+ if result.returncode != 0:
167
+ raise DeploymentKitError("ssh-add did not load the dedicated identity.")
168
+ after = agent_status(profile)
169
+ if not after["loaded"]:
170
+ raise DeploymentKitError("ssh-add reported success but the dedicated identity is not visible in the agent.")
171
+ return {**after, "status": "loaded", "mutated": True}