@mriekki/project-forge 4.1.0 → 4.3.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/assets/components.json +3 -3
- package/assets/deployment/distribution.json +4 -0
- package/assets/generated/ASSEMBLY.json +33 -13
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +3 -1
- package/assets/generated/core/core/skills/setup/SKILL.md +82 -1
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +137 -18
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +26 -0
- package/assets/generated/deployment/docs/PROJECT_TOPOLOGY_BINDING.md +44 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +56 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +7 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +106 -24
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +22 -1
- package/assets/generated/deployment/src/project_forge_deployment/project_topology.py +218 -0
- package/assets/generated/deployment/src/project_forge_deployment/proof_receipt.py +183 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +277 -23
- package/assets/generated/deployment/src/project_forge_deployment/topology_cli.py +85 -0
- package/package.json +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Durable workstation-local receipts for disposable environment proofs.
|
|
2
|
+
|
|
3
|
+
Receipts are diagnostic/readiness evidence, not deployment authorization or a workflow
|
|
4
|
+
database. They contain no credentials or mutable application data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .errors import DeploymentKitError
|
|
19
|
+
from .keys import key_fingerprint
|
|
20
|
+
from .profiles import load_profile
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
PROOF_CONTRACT_VERSION = 1
|
|
25
|
+
_ENV_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
26
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _root() -> Path:
|
|
30
|
+
configured = os.environ.get("PROJECT_FORGE_HOME")
|
|
31
|
+
return Path(configured).expanduser() if configured else Path.home() / ".project-forge"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _receipt_path(owner_repo: str, environment: str) -> Path:
|
|
35
|
+
if _REPO_RE.fullmatch(owner_repo) is None:
|
|
36
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
37
|
+
if _ENV_RE.fullmatch(environment) is None:
|
|
38
|
+
raise DeploymentKitError("environment must use lowercase letters, digits, and internal hyphens.")
|
|
39
|
+
owner, repo = owner_repo.split("/", 1)
|
|
40
|
+
return _root() / "evidence" / "environment-proofs" / owner / repo / f"{environment}.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _sha256_file(path: Path) -> str:
|
|
44
|
+
try:
|
|
45
|
+
data = path.read_bytes()
|
|
46
|
+
except OSError as error:
|
|
47
|
+
raise DeploymentKitError(f"Cannot read target trust evidence {path}: {error}") from error
|
|
48
|
+
return hashlib.sha256(data).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def current_target_signature(target_name: str) -> dict[str, Any]:
|
|
52
|
+
"""Return non-secret workstation/target identity used to invalidate stale proof."""
|
|
53
|
+
|
|
54
|
+
profile = load_profile(target_name)
|
|
55
|
+
identity = Path(profile["identityFile"])
|
|
56
|
+
known_hosts = Path(profile["knownHostsFile"])
|
|
57
|
+
signature = {
|
|
58
|
+
"targetName": profile["targetName"],
|
|
59
|
+
"targetOs": profile["targetOs"],
|
|
60
|
+
"hostName": profile["hostName"],
|
|
61
|
+
"sshAlias": profile["sshAlias"],
|
|
62
|
+
"user": profile["user"],
|
|
63
|
+
"port": profile["port"],
|
|
64
|
+
"addressOverride": profile["addressOverride"],
|
|
65
|
+
"identityFingerprint": key_fingerprint(identity),
|
|
66
|
+
"trustSha256": _sha256_file(known_hosts),
|
|
67
|
+
}
|
|
68
|
+
canonical = json.dumps(signature, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
69
|
+
return {**signature, "signatureSha256": hashlib.sha256(canonical).hexdigest()}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_environment_proof_receipt(result: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
required = {
|
|
74
|
+
"ownerRepo",
|
|
75
|
+
"environment",
|
|
76
|
+
"targetName",
|
|
77
|
+
"production",
|
|
78
|
+
"proof",
|
|
79
|
+
"cleanupState",
|
|
80
|
+
"finalMutationState",
|
|
81
|
+
}
|
|
82
|
+
missing = sorted(required.difference(result))
|
|
83
|
+
if missing:
|
|
84
|
+
raise DeploymentKitError("Environment proof result is missing receipt fields: " + ", ".join(missing))
|
|
85
|
+
|
|
86
|
+
path = _receipt_path(result["ownerRepo"], result["environment"])
|
|
87
|
+
target = current_target_signature(result["targetName"])
|
|
88
|
+
receipt = {
|
|
89
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
90
|
+
"proofContractVersion": PROOF_CONTRACT_VERSION,
|
|
91
|
+
"deploymentKitVersion": __version__,
|
|
92
|
+
"recordedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
93
|
+
"ownerRepo": result["ownerRepo"],
|
|
94
|
+
"environment": result["environment"],
|
|
95
|
+
"targetName": result["targetName"],
|
|
96
|
+
"production": bool(result["production"]),
|
|
97
|
+
"proof": result["proof"],
|
|
98
|
+
"cleanupState": result["cleanupState"],
|
|
99
|
+
"finalMutationState": result["finalMutationState"],
|
|
100
|
+
"registryDigest": result.get("registryDigest"),
|
|
101
|
+
"localImageId": result.get("localImageId"),
|
|
102
|
+
"remoteImageId": result.get("remoteImageId"),
|
|
103
|
+
"targetSignature": target,
|
|
104
|
+
}
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
temp = path.with_suffix(".tmp")
|
|
107
|
+
temp.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
108
|
+
temp.replace(path)
|
|
109
|
+
return {"path": str(path), "recordedAt": receipt["recordedAt"], "state": "recorded"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_environment_proof_receipt(
|
|
113
|
+
target_name: str,
|
|
114
|
+
*,
|
|
115
|
+
owner_repo: str,
|
|
116
|
+
environment: str,
|
|
117
|
+
production: bool,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Return whether a prior proof receipt is still applicable on this workstation."""
|
|
120
|
+
|
|
121
|
+
path = _receipt_path(owner_repo, environment)
|
|
122
|
+
if not path.is_file():
|
|
123
|
+
return {"state": "missing", "path": str(path), "mutated": False}
|
|
124
|
+
try:
|
|
125
|
+
receipt = json.loads(path.read_text(encoding="utf-8"))
|
|
126
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
127
|
+
return {
|
|
128
|
+
"state": "invalid",
|
|
129
|
+
"path": str(path),
|
|
130
|
+
"reason": f"Cannot read proof receipt: {error}",
|
|
131
|
+
"mutated": False,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
expected = {
|
|
135
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
136
|
+
"proofContractVersion": PROOF_CONTRACT_VERSION,
|
|
137
|
+
"deploymentKitVersion": __version__,
|
|
138
|
+
"ownerRepo": owner_repo,
|
|
139
|
+
"environment": environment,
|
|
140
|
+
"targetName": target_name,
|
|
141
|
+
"production": bool(production),
|
|
142
|
+
}
|
|
143
|
+
mismatches = [key for key, value in expected.items() if receipt.get(key) != value]
|
|
144
|
+
if mismatches:
|
|
145
|
+
return {
|
|
146
|
+
"state": "stale",
|
|
147
|
+
"path": str(path),
|
|
148
|
+
"reason": "Receipt context changed: " + ", ".join(mismatches),
|
|
149
|
+
"mutated": False,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if receipt.get("proof") != "passed" or receipt.get("cleanupState") not in {"removed", "absent"}:
|
|
153
|
+
return {
|
|
154
|
+
"state": "not-proven",
|
|
155
|
+
"path": str(path),
|
|
156
|
+
"reason": "Latest receipt is not a clean passing proof.",
|
|
157
|
+
"mutated": False,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
current = current_target_signature(target_name)
|
|
162
|
+
except DeploymentKitError as error:
|
|
163
|
+
return {"state": "stale", "path": str(path), "reason": str(error), "mutated": False}
|
|
164
|
+
previous = receipt.get("targetSignature") or {}
|
|
165
|
+
if previous.get("signatureSha256") != current.get("signatureSha256"):
|
|
166
|
+
return {
|
|
167
|
+
"state": "stale",
|
|
168
|
+
"path": str(path),
|
|
169
|
+
"reason": "Target/workstation identity or trust changed after the recorded proof.",
|
|
170
|
+
"mutated": False,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"state": "proven",
|
|
175
|
+
"path": str(path),
|
|
176
|
+
"recordedAt": receipt.get("recordedAt"),
|
|
177
|
+
"targetName": target_name,
|
|
178
|
+
"ownerRepo": owner_repo,
|
|
179
|
+
"environment": environment,
|
|
180
|
+
"production": bool(production),
|
|
181
|
+
"finalMutationState": receipt.get("finalMutationState"),
|
|
182
|
+
"mutated": False,
|
|
183
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
"""Strict SSH verification
|
|
1
|
+
"""Strict SSH verification and bounded diagnostics for logical targets."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import socket
|
|
7
8
|
import subprocess
|
|
@@ -9,9 +10,14 @@ from pathlib import Path
|
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
from .errors import DeploymentKitError
|
|
13
|
+
from .keys import key_fingerprint
|
|
12
14
|
from .security import check_ssh_material_security, validate_target_trust_file
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
_AUTH_METHODS_RE = re.compile(r"Authentications that can continue:\s*(?P<methods>[^\r\n]+)")
|
|
18
|
+
_PERMISSION_DENIED_RE = re.compile(r"Permission denied \((?P<methods>[^)]+)\)")
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
def required_tool(name: str) -> str:
|
|
16
22
|
path = shutil.which(name)
|
|
17
23
|
if path is None:
|
|
@@ -40,7 +46,7 @@ def resolve_addresses(profile: dict[str, Any]) -> list[str]:
|
|
|
40
46
|
return addresses
|
|
41
47
|
|
|
42
48
|
|
|
43
|
-
def
|
|
49
|
+
def _strict_transport_args(profile: dict[str, Any], address: str) -> list[str]:
|
|
44
50
|
return [
|
|
45
51
|
required_tool("ssh"),
|
|
46
52
|
"-F",
|
|
@@ -64,16 +70,6 @@ def ssh_args(profile: dict[str, Any], address: str, *, command: str = "true") ->
|
|
|
64
70
|
"-o",
|
|
65
71
|
f"HostKeyAlias={profile['sshAlias']}",
|
|
66
72
|
"-o",
|
|
67
|
-
f"IdentityFile={profile['identityFile']}",
|
|
68
|
-
"-o",
|
|
69
|
-
"IdentitiesOnly=yes",
|
|
70
|
-
"-o",
|
|
71
|
-
"PasswordAuthentication=no",
|
|
72
|
-
"-o",
|
|
73
|
-
"KbdInteractiveAuthentication=no",
|
|
74
|
-
"-o",
|
|
75
|
-
"PreferredAuthentications=publickey",
|
|
76
|
-
"-o",
|
|
77
73
|
"ForwardAgent=no",
|
|
78
74
|
"-o",
|
|
79
75
|
"ForwardX11=no",
|
|
@@ -81,11 +77,277 @@ def ssh_args(profile: dict[str, Any], address: str, *, command: str = "true") ->
|
|
|
81
77
|
"ClearAllForwardings=yes",
|
|
82
78
|
"-p",
|
|
83
79
|
str(profile["port"]),
|
|
84
|
-
f"{profile['user']}@{address}",
|
|
85
|
-
command,
|
|
86
80
|
]
|
|
87
81
|
|
|
88
82
|
|
|
83
|
+
def ssh_args(
|
|
84
|
+
profile: dict[str, Any],
|
|
85
|
+
address: str,
|
|
86
|
+
*,
|
|
87
|
+
command: str = "true",
|
|
88
|
+
verbose: bool = False,
|
|
89
|
+
) -> list[str]:
|
|
90
|
+
args = _strict_transport_args(profile, address)
|
|
91
|
+
if verbose:
|
|
92
|
+
args.insert(1, "-vv")
|
|
93
|
+
args.extend(
|
|
94
|
+
[
|
|
95
|
+
"-o",
|
|
96
|
+
f"IdentityFile={profile['identityFile']}",
|
|
97
|
+
"-o",
|
|
98
|
+
"IdentitiesOnly=yes",
|
|
99
|
+
"-o",
|
|
100
|
+
"PasswordAuthentication=no",
|
|
101
|
+
"-o",
|
|
102
|
+
"KbdInteractiveAuthentication=no",
|
|
103
|
+
"-o",
|
|
104
|
+
"PreferredAuthentications=publickey",
|
|
105
|
+
f"{profile['user']}@{address}",
|
|
106
|
+
command,
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
return args
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def authentication_probe_args(profile: dict[str, Any], address: str) -> list[str]:
|
|
113
|
+
"""Build a read-only SSH handshake that discovers advertised auth methods.
|
|
114
|
+
|
|
115
|
+
Host identity is still verified through the configured target-specific trust file.
|
|
116
|
+
All actual client authentication mechanisms are disabled so the server's initial
|
|
117
|
+
authentication-method advertisement can be observed without attempting credentials.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
args = _strict_transport_args(profile, address)
|
|
121
|
+
args.insert(1, "-vv")
|
|
122
|
+
args.extend(
|
|
123
|
+
[
|
|
124
|
+
"-o",
|
|
125
|
+
"PubkeyAuthentication=no",
|
|
126
|
+
"-o",
|
|
127
|
+
"PasswordAuthentication=no",
|
|
128
|
+
"-o",
|
|
129
|
+
"KbdInteractiveAuthentication=no",
|
|
130
|
+
"-o",
|
|
131
|
+
"GSSAPIAuthentication=no",
|
|
132
|
+
"-o",
|
|
133
|
+
"HostbasedAuthentication=no",
|
|
134
|
+
f"{profile['user']}@{address}",
|
|
135
|
+
"true",
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
return args
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _completed(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
142
|
+
return subprocess.run(
|
|
143
|
+
args,
|
|
144
|
+
check=False,
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
encoding="utf-8",
|
|
148
|
+
errors="replace",
|
|
149
|
+
stdin=subprocess.DEVNULL,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _auth_methods(text: str) -> list[str]:
|
|
154
|
+
methods: list[str] = []
|
|
155
|
+
for match in _AUTH_METHODS_RE.finditer(text):
|
|
156
|
+
for method in match.group("methods").split(","):
|
|
157
|
+
value = method.strip()
|
|
158
|
+
if value and value not in methods:
|
|
159
|
+
methods.append(value)
|
|
160
|
+
if not methods:
|
|
161
|
+
denied = _PERMISSION_DENIED_RE.search(text)
|
|
162
|
+
if denied:
|
|
163
|
+
for method in denied.group("methods").split(","):
|
|
164
|
+
value = method.strip()
|
|
165
|
+
if value and value not in methods:
|
|
166
|
+
methods.append(value)
|
|
167
|
+
return methods
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def probe_authentication_methods(profile: dict[str, Any], address: str) -> dict[str, Any]:
|
|
171
|
+
result = _completed(authentication_probe_args(profile, address))
|
|
172
|
+
text = "\n".join(part for part in (result.stderr, result.stdout) if part)
|
|
173
|
+
methods = _auth_methods(text)
|
|
174
|
+
return {
|
|
175
|
+
"address": address,
|
|
176
|
+
"methods": methods,
|
|
177
|
+
"passwordSupported": "password" in methods or "keyboard-interactive" in methods,
|
|
178
|
+
"publicKeySupported": "publickey" in methods,
|
|
179
|
+
"observed": bool(methods),
|
|
180
|
+
"returnCode": result.returncode,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _tcp_probe(profile: dict[str, Any], address: str) -> dict[str, Any]:
|
|
185
|
+
try:
|
|
186
|
+
with socket.create_connection((address, int(profile["port"])), timeout=8):
|
|
187
|
+
pass
|
|
188
|
+
return {"state": "reachable", "address": address, "port": int(profile["port"])}
|
|
189
|
+
except OSError as error:
|
|
190
|
+
return {
|
|
191
|
+
"state": "unreachable",
|
|
192
|
+
"address": address,
|
|
193
|
+
"port": int(profile["port"]),
|
|
194
|
+
"reason": str(error),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _classify_failure(text: str, *, auth: dict[str, Any], offered: bool, accepted: bool) -> str:
|
|
199
|
+
lower = text.lower()
|
|
200
|
+
if "remote host identification has changed" in lower or "host key verification failed" in lower:
|
|
201
|
+
return "host-identity-rejected"
|
|
202
|
+
if any(
|
|
203
|
+
marker in lower
|
|
204
|
+
for marker in (
|
|
205
|
+
"sign_and_send_pubkey: signing failed",
|
|
206
|
+
"agent refused operation",
|
|
207
|
+
"incorrect passphrase",
|
|
208
|
+
"load key",
|
|
209
|
+
)
|
|
210
|
+
):
|
|
211
|
+
return "local-signing-failed"
|
|
212
|
+
if "connection timed out" in lower or "connection refused" in lower or "no route to host" in lower:
|
|
213
|
+
return "transport-failed"
|
|
214
|
+
if "permission denied" in lower:
|
|
215
|
+
if accepted:
|
|
216
|
+
return "remote-account-policy-rejected"
|
|
217
|
+
if offered or auth.get("publicKeySupported"):
|
|
218
|
+
return "remote-authentication-rejected"
|
|
219
|
+
return "authentication-rejected"
|
|
220
|
+
return "ssh-verification-failed"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
"""Return safe structured SSH failure evidence without changing workstation or target state."""
|
|
225
|
+
|
|
226
|
+
identity = Path(profile["identityFile"])
|
|
227
|
+
known_hosts = Path(profile["knownHostsFile"])
|
|
228
|
+
if not identity.is_file():
|
|
229
|
+
return {
|
|
230
|
+
"targetName": profile["targetName"],
|
|
231
|
+
"state": "failed",
|
|
232
|
+
"failureCategory": "missing-identity",
|
|
233
|
+
"reason": f"Dedicated private key is missing: {identity}",
|
|
234
|
+
"mutated": False,
|
|
235
|
+
}
|
|
236
|
+
if not known_hosts.is_file() or known_hosts.stat().st_size == 0:
|
|
237
|
+
return {
|
|
238
|
+
"targetName": profile["targetName"],
|
|
239
|
+
"state": "failed",
|
|
240
|
+
"failureCategory": "missing-trust",
|
|
241
|
+
"reason": f"Target-specific trust file is missing or empty: {known_hosts}",
|
|
242
|
+
"mutated": False,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
check_ssh_material_security(identity, known_hosts)
|
|
247
|
+
validate_target_trust_file(profile)
|
|
248
|
+
except DeploymentKitError as error:
|
|
249
|
+
return {
|
|
250
|
+
"targetName": profile["targetName"],
|
|
251
|
+
"state": "failed",
|
|
252
|
+
"failureCategory": "local-security-or-trust-invalid",
|
|
253
|
+
"reason": str(error),
|
|
254
|
+
"mutated": False,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
addresses = resolve_addresses(profile)
|
|
259
|
+
except DeploymentKitError as error:
|
|
260
|
+
return {
|
|
261
|
+
"targetName": profile["targetName"],
|
|
262
|
+
"state": "failed",
|
|
263
|
+
"failureCategory": "name-resolution-failed",
|
|
264
|
+
"reason": str(error),
|
|
265
|
+
"mutated": False,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fingerprint = key_fingerprint(identity)
|
|
269
|
+
attempts: list[dict[str, Any]] = []
|
|
270
|
+
for address in addresses:
|
|
271
|
+
tcp = _tcp_probe(profile, address)
|
|
272
|
+
if tcp["state"] != "reachable":
|
|
273
|
+
attempts.append({"address": address, "tcp": tcp, "state": "transport-failed"})
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
auth = probe_authentication_methods(profile, address)
|
|
277
|
+
result = _completed(ssh_args(profile, address, verbose=True))
|
|
278
|
+
text = "\n".join(part for part in (result.stderr, result.stdout) if part)
|
|
279
|
+
offered = "offering public key" in text.lower()
|
|
280
|
+
accepted = "server accepts key" in text.lower()
|
|
281
|
+
if result.returncode == 0:
|
|
282
|
+
return {
|
|
283
|
+
"targetName": profile["targetName"],
|
|
284
|
+
"state": "verified",
|
|
285
|
+
"address": address,
|
|
286
|
+
"identityFingerprint": fingerprint,
|
|
287
|
+
"authentication": auth,
|
|
288
|
+
"offeredConfiguredIdentity": offered,
|
|
289
|
+
"serverAcceptedConfiguredIdentity": accepted,
|
|
290
|
+
"mutated": False,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
attempts.append(
|
|
294
|
+
{
|
|
295
|
+
"address": address,
|
|
296
|
+
"tcp": tcp,
|
|
297
|
+
"authentication": auth,
|
|
298
|
+
"identityFingerprint": fingerprint,
|
|
299
|
+
"offeredConfiguredIdentity": offered,
|
|
300
|
+
"serverAcceptedConfiguredIdentity": accepted,
|
|
301
|
+
"returnCode": result.returncode,
|
|
302
|
+
"failureCategory": _classify_failure(
|
|
303
|
+
text,
|
|
304
|
+
auth=auth,
|
|
305
|
+
offered=offered,
|
|
306
|
+
accepted=accepted,
|
|
307
|
+
),
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
category = "transport-failed"
|
|
312
|
+
for candidate in (
|
|
313
|
+
"host-identity-rejected",
|
|
314
|
+
"local-signing-failed",
|
|
315
|
+
"remote-account-policy-rejected",
|
|
316
|
+
"remote-authentication-rejected",
|
|
317
|
+
"authentication-rejected",
|
|
318
|
+
"ssh-verification-failed",
|
|
319
|
+
):
|
|
320
|
+
if any(item.get("failureCategory") == candidate for item in attempts):
|
|
321
|
+
category = candidate
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
guidance = {
|
|
325
|
+
"transport-failed": "Verify the configured address/port and network path; do not scan or silently switch targets.",
|
|
326
|
+
"host-identity-rejected": "Re-verify host identity independently before any trust rotation.",
|
|
327
|
+
"local-signing-failed": "Repair local key/passphrase/agent use; do not weaken remote authentication policy.",
|
|
328
|
+
"remote-authentication-rejected": (
|
|
329
|
+
"The configured public-key path reached the server but authentication was rejected. "
|
|
330
|
+
"Verify authorized_keys and remote account/source access policy from a trusted console or already-authorized session."
|
|
331
|
+
),
|
|
332
|
+
"remote-account-policy-rejected": (
|
|
333
|
+
"The server accepted the configured key but the session was still rejected. "
|
|
334
|
+
"Inspect remote account/source policy from a trusted console or already-authorized session."
|
|
335
|
+
),
|
|
336
|
+
"authentication-rejected": "Inspect the server-advertised authentication policy and the configured account.",
|
|
337
|
+
"ssh-verification-failed": "Inspect the structured attempt evidence before changing target or SSH policy.",
|
|
338
|
+
}[category]
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"targetName": profile["targetName"],
|
|
342
|
+
"state": "failed",
|
|
343
|
+
"failureCategory": category,
|
|
344
|
+
"identityFingerprint": fingerprint,
|
|
345
|
+
"attempts": attempts,
|
|
346
|
+
"guidance": guidance,
|
|
347
|
+
"mutated": False,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
89
351
|
def verify_access(profile: dict[str, Any]) -> dict[str, str]:
|
|
90
352
|
identity = Path(profile["identityFile"])
|
|
91
353
|
known_hosts = Path(profile["knownHostsFile"])
|
|
@@ -101,15 +363,7 @@ def verify_access(profile: dict[str, Any]) -> dict[str, str]:
|
|
|
101
363
|
|
|
102
364
|
failures: list[str] = []
|
|
103
365
|
for address in resolve_addresses(profile):
|
|
104
|
-
result =
|
|
105
|
-
ssh_args(profile, address),
|
|
106
|
-
check=False,
|
|
107
|
-
capture_output=True,
|
|
108
|
-
text=True,
|
|
109
|
-
encoding="utf-8",
|
|
110
|
-
errors="replace",
|
|
111
|
-
stdin=subprocess.DEVNULL,
|
|
112
|
-
)
|
|
366
|
+
result = _completed(ssh_args(profile, address))
|
|
113
367
|
if result.returncode == 0:
|
|
114
368
|
return {"targetName": profile["targetName"], "address": address, "status": "verified"}
|
|
115
369
|
failures.append(f"{address}: exit {result.returncode}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CLI for deterministic project-owned deployment topology binding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import DeploymentKitError
|
|
12
|
+
from .project_topology import apply_environment_binding, plan_environment_binding
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _print(value: Any, *, as_json: bool) -> None:
|
|
16
|
+
if as_json:
|
|
17
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
18
|
+
return
|
|
19
|
+
if isinstance(value, dict):
|
|
20
|
+
for key, item in value.items():
|
|
21
|
+
if isinstance(item, (dict, list)):
|
|
22
|
+
print(f"{key}: {json.dumps(item, sort_keys=True)}")
|
|
23
|
+
else:
|
|
24
|
+
print(f"{key}: {item}")
|
|
25
|
+
return
|
|
26
|
+
print(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
30
|
+
if args.yes:
|
|
31
|
+
return
|
|
32
|
+
entered = input(f"Type {phrase} to {action}: ").strip()
|
|
33
|
+
if entered != phrase:
|
|
34
|
+
raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(prog="project-forge-deploy topology")
|
|
39
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
for command in ("plan-binding", "bind"):
|
|
42
|
+
item = sub.add_parser(
|
|
43
|
+
command,
|
|
44
|
+
help=(
|
|
45
|
+
"Preview completion of one deferred remote-target binding"
|
|
46
|
+
if command == "plan-binding"
|
|
47
|
+
else "Persist one reviewed deferred remote-target binding"
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
item.add_argument("environment")
|
|
51
|
+
item.add_argument("--target-profile", required=True)
|
|
52
|
+
item.add_argument("--root", type=Path, default=Path("."))
|
|
53
|
+
item.add_argument("--json", action="store_true")
|
|
54
|
+
if command == "bind":
|
|
55
|
+
item.add_argument("--yes", action="store_true")
|
|
56
|
+
return parser
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> int:
|
|
60
|
+
args = build_parser().parse_args(argv)
|
|
61
|
+
try:
|
|
62
|
+
plan = plan_environment_binding(args.root, args.environment, args.target_profile)
|
|
63
|
+
if args.command == "plan-binding":
|
|
64
|
+
_print(plan, as_json=args.json)
|
|
65
|
+
return 0
|
|
66
|
+
if plan["status"] == "already-bound":
|
|
67
|
+
_print(plan, as_json=args.json)
|
|
68
|
+
return 0
|
|
69
|
+
if not args.json:
|
|
70
|
+
_print(plan, as_json=False)
|
|
71
|
+
_confirm(
|
|
72
|
+
args,
|
|
73
|
+
plan["requiredAuthorization"],
|
|
74
|
+
f"bind project environment {args.environment} to target profile {args.target_profile}",
|
|
75
|
+
)
|
|
76
|
+
result = apply_environment_binding(plan)
|
|
77
|
+
_print(result, as_json=args.json)
|
|
78
|
+
return 0
|
|
79
|
+
except DeploymentKitError as error:
|
|
80
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|