@mriekki/lean 0.1.0 → 0.1.1
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 -67
- package/assets/components.json +33 -33
- package/assets/core/distribution.json +51 -51
- package/assets/deployment/distribution.json +34 -34
- package/assets/generated/ASSEMBLY.json +28 -28
- 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,121 +1,121 @@
|
|
|
1
|
-
"""Read-only workstation and target readiness checks."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import shutil
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from .errors import DeploymentKitError
|
|
10
|
-
from .profiles import config_root, load_profile, target_profile_path
|
|
11
|
-
from .security import check_ssh_material_security, validate_target_trust_file
|
|
12
|
-
from .ssh import resolve_addresses
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def workstation_doctor(*, home: Path | None = None) -> dict[str, Any]:
|
|
16
|
-
root = config_root(home)
|
|
17
|
-
targets_dir = root / "targets"
|
|
18
|
-
profiles = []
|
|
19
|
-
if targets_dir.is_dir() and not targets_dir.is_symlink():
|
|
20
|
-
profiles = sorted(path.stem for path in targets_dir.glob("*.json") if path.is_file() and not path.is_symlink())
|
|
21
|
-
|
|
22
|
-
tools = {name: shutil.which(name) for name in ("ssh", "ssh-keygen", "ssh-keyscan", "ssh-add")}
|
|
23
|
-
missing_tools = [name for name, path in tools.items() if path is None]
|
|
24
|
-
|
|
25
|
-
if not root.exists() and not profiles:
|
|
26
|
-
state = "fresh-workstation"
|
|
27
|
-
elif missing_tools:
|
|
28
|
-
state = "blocked"
|
|
29
|
-
elif profiles:
|
|
30
|
-
state = "configured"
|
|
31
|
-
else:
|
|
32
|
-
state = "partially-configured"
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
"state": state,
|
|
36
|
-
"configRoot": str(root),
|
|
37
|
-
"profiles": profiles,
|
|
38
|
-
"tools": tools,
|
|
39
|
-
"missingTools": missing_tools,
|
|
40
|
-
"mutated": False,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def target_doctor(name: str, *, home: Path | None = None) -> dict[str, Any]:
|
|
45
|
-
path = target_profile_path(name, home)
|
|
46
|
-
if not path.exists():
|
|
47
|
-
return {
|
|
48
|
-
"targetName": name,
|
|
49
|
-
"state": "needs-profile",
|
|
50
|
-
"profilePath": str(path),
|
|
51
|
-
"nextAction": "configure-target-profile",
|
|
52
|
-
"mutated": False,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
try:
|
|
56
|
-
profile = load_profile(name, home=home)
|
|
57
|
-
except DeploymentKitError as error:
|
|
58
|
-
return {
|
|
59
|
-
"targetName": name,
|
|
60
|
-
"state": "blocked",
|
|
61
|
-
"profilePath": str(path),
|
|
62
|
-
"reason": str(error),
|
|
63
|
-
"mutated": False,
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
identity = Path(profile["identityFile"])
|
|
67
|
-
known_hosts = Path(profile["knownHostsFile"])
|
|
68
|
-
if not identity.is_file():
|
|
69
|
-
return {
|
|
70
|
-
"targetName": name,
|
|
71
|
-
"state": "needs-key",
|
|
72
|
-
"profilePath": str(path),
|
|
73
|
-
"identityFile": str(identity),
|
|
74
|
-
"nextAction": "create-or-select-dedicated-key",
|
|
75
|
-
"mutated": False,
|
|
76
|
-
}
|
|
77
|
-
if not known_hosts.is_file() or known_hosts.stat().st_size == 0:
|
|
78
|
-
return {
|
|
79
|
-
"targetName": name,
|
|
80
|
-
"state": "needs-trust",
|
|
81
|
-
"profilePath": str(path),
|
|
82
|
-
"knownHostsFile": str(known_hosts),
|
|
83
|
-
"nextAction": "verify-console-fingerprint-before-trust-enrollment",
|
|
84
|
-
"mutated": False,
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
check_ssh_material_security(identity, known_hosts)
|
|
89
|
-
validate_target_trust_file(profile)
|
|
90
|
-
except DeploymentKitError as error:
|
|
91
|
-
return {
|
|
92
|
-
"targetName": name,
|
|
93
|
-
"state": "blocked",
|
|
94
|
-
"profilePath": str(path),
|
|
95
|
-
"reason": str(error),
|
|
96
|
-
"nextAction": "repair-ssh-material-or-trust",
|
|
97
|
-
"mutated": False,
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
try:
|
|
101
|
-
addresses = resolve_addresses(profile)
|
|
102
|
-
except DeploymentKitError as error:
|
|
103
|
-
return {
|
|
104
|
-
"targetName": name,
|
|
105
|
-
"state": "needs-address",
|
|
106
|
-
"profilePath": str(path),
|
|
107
|
-
"hostName": profile["hostName"],
|
|
108
|
-
"reason": str(error),
|
|
109
|
-
"nextAction": "set-separately-verified-address-override",
|
|
110
|
-
"mutated": False,
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
"targetName": name,
|
|
115
|
-
"state": "ready-to-verify",
|
|
116
|
-
"profilePath": str(path),
|
|
117
|
-
"hostName": profile["hostName"],
|
|
118
|
-
"addresses": addresses,
|
|
119
|
-
"addressSource": "override" if profile["addressOverride"] else "dns-mdns",
|
|
120
|
-
"mutated": False,
|
|
121
|
-
}
|
|
1
|
+
"""Read-only workstation and target readiness checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import DeploymentKitError
|
|
10
|
+
from .profiles import config_root, load_profile, target_profile_path
|
|
11
|
+
from .security import check_ssh_material_security, validate_target_trust_file
|
|
12
|
+
from .ssh import resolve_addresses
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def workstation_doctor(*, home: Path | None = None) -> dict[str, Any]:
|
|
16
|
+
root = config_root(home)
|
|
17
|
+
targets_dir = root / "targets"
|
|
18
|
+
profiles = []
|
|
19
|
+
if targets_dir.is_dir() and not targets_dir.is_symlink():
|
|
20
|
+
profiles = sorted(path.stem for path in targets_dir.glob("*.json") if path.is_file() and not path.is_symlink())
|
|
21
|
+
|
|
22
|
+
tools = {name: shutil.which(name) for name in ("ssh", "ssh-keygen", "ssh-keyscan", "ssh-add")}
|
|
23
|
+
missing_tools = [name for name, path in tools.items() if path is None]
|
|
24
|
+
|
|
25
|
+
if not root.exists() and not profiles:
|
|
26
|
+
state = "fresh-workstation"
|
|
27
|
+
elif missing_tools:
|
|
28
|
+
state = "blocked"
|
|
29
|
+
elif profiles:
|
|
30
|
+
state = "configured"
|
|
31
|
+
else:
|
|
32
|
+
state = "partially-configured"
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
"state": state,
|
|
36
|
+
"configRoot": str(root),
|
|
37
|
+
"profiles": profiles,
|
|
38
|
+
"tools": tools,
|
|
39
|
+
"missingTools": missing_tools,
|
|
40
|
+
"mutated": False,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def target_doctor(name: str, *, home: Path | None = None) -> dict[str, Any]:
|
|
45
|
+
path = target_profile_path(name, home)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return {
|
|
48
|
+
"targetName": name,
|
|
49
|
+
"state": "needs-profile",
|
|
50
|
+
"profilePath": str(path),
|
|
51
|
+
"nextAction": "configure-target-profile",
|
|
52
|
+
"mutated": False,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
profile = load_profile(name, home=home)
|
|
57
|
+
except DeploymentKitError as error:
|
|
58
|
+
return {
|
|
59
|
+
"targetName": name,
|
|
60
|
+
"state": "blocked",
|
|
61
|
+
"profilePath": str(path),
|
|
62
|
+
"reason": str(error),
|
|
63
|
+
"mutated": False,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
identity = Path(profile["identityFile"])
|
|
67
|
+
known_hosts = Path(profile["knownHostsFile"])
|
|
68
|
+
if not identity.is_file():
|
|
69
|
+
return {
|
|
70
|
+
"targetName": name,
|
|
71
|
+
"state": "needs-key",
|
|
72
|
+
"profilePath": str(path),
|
|
73
|
+
"identityFile": str(identity),
|
|
74
|
+
"nextAction": "create-or-select-dedicated-key",
|
|
75
|
+
"mutated": False,
|
|
76
|
+
}
|
|
77
|
+
if not known_hosts.is_file() or known_hosts.stat().st_size == 0:
|
|
78
|
+
return {
|
|
79
|
+
"targetName": name,
|
|
80
|
+
"state": "needs-trust",
|
|
81
|
+
"profilePath": str(path),
|
|
82
|
+
"knownHostsFile": str(known_hosts),
|
|
83
|
+
"nextAction": "verify-console-fingerprint-before-trust-enrollment",
|
|
84
|
+
"mutated": False,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
check_ssh_material_security(identity, known_hosts)
|
|
89
|
+
validate_target_trust_file(profile)
|
|
90
|
+
except DeploymentKitError as error:
|
|
91
|
+
return {
|
|
92
|
+
"targetName": name,
|
|
93
|
+
"state": "blocked",
|
|
94
|
+
"profilePath": str(path),
|
|
95
|
+
"reason": str(error),
|
|
96
|
+
"nextAction": "repair-ssh-material-or-trust",
|
|
97
|
+
"mutated": False,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
addresses = resolve_addresses(profile)
|
|
102
|
+
except DeploymentKitError as error:
|
|
103
|
+
return {
|
|
104
|
+
"targetName": name,
|
|
105
|
+
"state": "needs-address",
|
|
106
|
+
"profilePath": str(path),
|
|
107
|
+
"hostName": profile["hostName"],
|
|
108
|
+
"reason": str(error),
|
|
109
|
+
"nextAction": "set-separately-verified-address-override",
|
|
110
|
+
"mutated": False,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"targetName": name,
|
|
115
|
+
"state": "ready-to-verify",
|
|
116
|
+
"profilePath": str(path),
|
|
117
|
+
"hostName": profile["hostName"],
|
|
118
|
+
"addresses": addresses,
|
|
119
|
+
"addressSource": "override" if profile["addressOverride"] else "dns-mdns",
|
|
120
|
+
"mutated": False,
|
|
121
|
+
}
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
"""Top-level console entrypoint for Deployment Kit command groups."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
from .app_cli import main as app_main
|
|
8
|
-
from .cli import main as core_main
|
|
9
|
-
from .sqlite_cli import main as sqlite_main
|
|
10
|
-
from .teardown_cli import main as teardown_main
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def main(argv: list[str] | None = None) -> int:
|
|
14
|
-
args = list(sys.argv[1:] if argv is None else argv)
|
|
15
|
-
if args and args[0] == "app":
|
|
16
|
-
return app_main(args[1:])
|
|
17
|
-
if args and args[0] == "sqlite":
|
|
18
|
-
return sqlite_main(args[1:])
|
|
19
|
-
if args and args[0] == "tear-down":
|
|
20
|
-
return teardown_main(args[1:])
|
|
21
|
-
if not args or args == ["--help"] or args == ["-h"]:
|
|
22
|
-
print("Command group: app Generic immutable application runtime inventory/deployment")
|
|
23
|
-
print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
|
|
24
|
-
print("Command group: tear-down Scoped destructive POC environment/local cleanup")
|
|
25
|
-
return core_main(args)
|
|
1
|
+
"""Top-level console entrypoint for Deployment Kit command groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .app_cli import main as app_main
|
|
8
|
+
from .cli import main as core_main
|
|
9
|
+
from .sqlite_cli import main as sqlite_main
|
|
10
|
+
from .teardown_cli import main as teardown_main
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
15
|
+
if args and args[0] == "app":
|
|
16
|
+
return app_main(args[1:])
|
|
17
|
+
if args and args[0] == "sqlite":
|
|
18
|
+
return sqlite_main(args[1:])
|
|
19
|
+
if args and args[0] == "tear-down":
|
|
20
|
+
return teardown_main(args[1:])
|
|
21
|
+
if not args or args == ["--help"] or args == ["-h"]:
|
|
22
|
+
print("Command group: app Generic immutable application runtime inventory/deployment")
|
|
23
|
+
print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
|
|
24
|
+
print("Command group: tear-down Scoped destructive POC environment/local cleanup")
|
|
25
|
+
return core_main(args)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"""Deployment Kit exception types."""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class DeploymentKitError(Exception):
|
|
5
|
-
"""Expected fail-closed operational or configuration error."""
|
|
1
|
+
"""Deployment Kit exception types."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DeploymentKitError(Exception):
|
|
5
|
+
"""Expected fail-closed operational or configuration error."""
|
|
@@ -1,171 +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}
|
|
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}
|