@mriekki/lean 0.1.0 → 0.2.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 +71 -67
- package/assets/components.json +33 -33
- package/assets/core/distribution.json +52 -51
- package/assets/deployment/distribution.json +34 -34
- package/assets/generated/ASSEMBLY.json +38 -33
- package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
- package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
- package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
- package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
- package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
- package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
- package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
- package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
- package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
- package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
- package/bin/lean.mjs +9 -9
- package/package.json +33 -33
- package/src/cli.mjs +131 -131
- package/src/engine.mjs +243 -243
- package/src/package-data.mjs +18 -18
|
@@ -1,204 +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
|
|
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
|