@mriekki/lean 0.0.0-stage → 0.1.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 +67 -3
- package/assets/components.json +33 -0
- package/assets/core/distribution.json +51 -0
- package/assets/deployment/distribution.json +34 -0
- package/assets/generated/ASSEMBLY.json +382 -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/lean-docs.instructions.md +14 -0
- package/assets/generated/core/core/instructions/lean-workflow.instructions.md +35 -0
- package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
- package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +19 -0
- package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +82 -0
- package/assets/generated/core/core/learning/LEARNING_MAP.md +51 -0
- package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +31 -0
- package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
- package/assets/generated/core/core/learning/SQL_DRILLS.md +27 -0
- package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +21 -0
- package/assets/generated/core/core/process/COACHING_GUIDE.md +57 -0
- package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
- package/assets/generated/core/core/process/HUMAN_GATES.md +81 -0
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +62 -0
- package/assets/generated/core/core/process/PRODUCTION_ENABLEMENT.md +59 -0
- package/assets/generated/core/core/process/PR_WORKFLOW.md +53 -0
- package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -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 +136 -0
- package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
- package/assets/generated/core/core/skills/core-sync/SKILL.md +220 -0
- package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
- package/assets/generated/core/core/skills/deliver/SKILL.md +315 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +193 -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 +166 -0
- package/assets/generated/core/core/skills/production-readiness/SKILL.md +357 -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/start-work/SKILL.md +178 -0
- package/assets/generated/core/core/skills/tear-down/SKILL.md +138 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +206 -0
- package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -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/lean-workspace.json +12 -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/deployment/distribution/lean-poc-deploy.py +8 -0
- package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -0
- package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -0
- package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -0
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -0
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +416 -0
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -0
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -0
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -0
- package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -0
- package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -0
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -0
- package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -0
- package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -0
- package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -0
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -0
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -0
- package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +335 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +340 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +86 -0
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -0
- package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -0
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -0
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -0
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -0
- package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -0
- package/bin/lean.mjs +9 -0
- package/package.json +33 -6
- package/src/cli.mjs +131 -0
- package/src/engine.mjs +243 -0
- package/src/package-data.mjs +18 -0
|
@@ -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"lean-poc:{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}
|
|
@@ -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("LEAN_POC_HOME")
|
|
34
|
+
if override:
|
|
35
|
+
candidate = Path(override).expanduser()
|
|
36
|
+
if not candidate.is_absolute():
|
|
37
|
+
raise DeploymentKitError("LEAN_POC_HOME must be an absolute path.")
|
|
38
|
+
return candidate.resolve()
|
|
39
|
+
return ((home or Path.home()).resolve() / ".lean-poc").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
|