@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,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}
|