ldw-deploy 0.2.1__py3-none-any.whl
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.
- ldw_deploy/__init__.py +1 -0
- ldw_deploy/assets/ldw-remote-deploy.py +526 -0
- ldw_deploy/commands.py +161 -0
- ldw_deploy/extension.py +58 -0
- ldw_deploy/models.py +100 -0
- ldw_deploy/service.py +1169 -0
- ldw_deploy/state.py +197 -0
- ldw_deploy-0.2.1.dist-info/METADATA +24 -0
- ldw_deploy-0.2.1.dist-info/RECORD +13 -0
- ldw_deploy-0.2.1.dist-info/WHEEL +4 -0
- ldw_deploy-0.2.1.dist-info/entry_points.txt +6 -0
- ldw_deploy-0.2.1.dist-info/licenses/LICENSE +201 -0
- ldw_deploy-0.2.1.dist-info/licenses/NOTICE +4 -0
ldw_deploy/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SSH production targets and deployments for the Local DevOps Workbench."""
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fcntl
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pwd
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from contextlib import suppress
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
PROJECTS_ROOT = Path("/srv/ldw/projects")
|
|
22
|
+
STAGING_ROOT = Path("/home/devops/ldw")
|
|
23
|
+
DOCKER = "/usr/bin/docker"
|
|
24
|
+
COMPONENT = re.compile(r"[a-z][a-z0-9-]{1,62}")
|
|
25
|
+
BUILD = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9.-]{0,127}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _component(value: str, *, build: bool = False) -> str:
|
|
29
|
+
pattern = BUILD if build else COMPONENT
|
|
30
|
+
if pattern.fullmatch(value) is None:
|
|
31
|
+
raise ValueError(f"invalid deployment component: {value!r}")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _relative(value: str) -> str:
|
|
36
|
+
path = PurePosixPath(value)
|
|
37
|
+
if not value or path.is_absolute() or value == "." or ".." in path.parts:
|
|
38
|
+
raise ValueError(f"invalid release path: {value!r}")
|
|
39
|
+
return str(path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _staged(name: str) -> Path:
|
|
43
|
+
if Path(name).name != name or name in {"", ".", ".."}:
|
|
44
|
+
raise ValueError("invalid staging filename")
|
|
45
|
+
path = STAGING_ROOT / name
|
|
46
|
+
if not path.is_file() or path.is_symlink():
|
|
47
|
+
raise ValueError(f"staged input is not a regular file: {name}")
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sha256(path: Path) -> str:
|
|
52
|
+
with path.open("rb") as handle:
|
|
53
|
+
return hashlib.file_digest(handle, "sha256").hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _verified_staged(record: object) -> Path:
|
|
57
|
+
if not isinstance(record, dict):
|
|
58
|
+
raise ValueError("invalid staged input record")
|
|
59
|
+
name = record.get("name")
|
|
60
|
+
checksum = record.get("sha256")
|
|
61
|
+
if not isinstance(name, str) or not isinstance(checksum, str):
|
|
62
|
+
raise ValueError("staged input record requires name and sha256")
|
|
63
|
+
path = _staged(name)
|
|
64
|
+
if _sha256(path) != checksum:
|
|
65
|
+
raise ValueError(f"staged input checksum mismatch: {name}")
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _safe_extract(bundle: Path, destination: Path) -> None:
|
|
70
|
+
with tarfile.open(bundle) as archive:
|
|
71
|
+
members = archive.getmembers()
|
|
72
|
+
for member in members:
|
|
73
|
+
path = PurePosixPath(member.name)
|
|
74
|
+
if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk():
|
|
75
|
+
raise ValueError(f"unsafe release archive member: {member.name}")
|
|
76
|
+
archive.extractall(destination, members=members, filter="data")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _metadata(
|
|
80
|
+
path: Path,
|
|
81
|
+
project: str,
|
|
82
|
+
build_id: str,
|
|
83
|
+
expected_sha256: str,
|
|
84
|
+
) -> dict[str, object]:
|
|
85
|
+
if re.fullmatch(r"[a-f0-9]{64}", expected_sha256) is None:
|
|
86
|
+
raise ValueError("invalid deployment metadata checksum")
|
|
87
|
+
if _sha256(path) != expected_sha256:
|
|
88
|
+
raise ValueError("deployment metadata checksum mismatch")
|
|
89
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
90
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
|
91
|
+
raise ValueError("invalid deployment metadata")
|
|
92
|
+
if payload.get("project_id") != project or payload.get("build_id") != build_id:
|
|
93
|
+
raise ValueError("deployment metadata identity mismatch")
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _health_url(value: object) -> str:
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
raise ValueError("deployment metadata has no health_url")
|
|
100
|
+
parsed = urlsplit(value)
|
|
101
|
+
if (
|
|
102
|
+
parsed.scheme != "http"
|
|
103
|
+
or parsed.hostname not in {"127.0.0.1", "localhost"}
|
|
104
|
+
or parsed.port is None
|
|
105
|
+
or parsed.username is not None
|
|
106
|
+
or parsed.password is not None
|
|
107
|
+
):
|
|
108
|
+
raise ValueError("deployment health URL must use loopback HTTP")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _compose_argv(release: Path, metadata: dict[str, object], *args: str) -> list[str]:
|
|
113
|
+
project = str(metadata["project_id"])
|
|
114
|
+
compose_files = metadata.get("compose_files")
|
|
115
|
+
profiles = metadata.get("profiles", [])
|
|
116
|
+
if not isinstance(compose_files, list) or not compose_files:
|
|
117
|
+
raise ValueError("deployment metadata has no Compose files")
|
|
118
|
+
if not isinstance(profiles, list):
|
|
119
|
+
raise ValueError("deployment metadata has invalid profiles")
|
|
120
|
+
argv = [DOCKER, "compose", "-p", f"ldw-{project}"]
|
|
121
|
+
for relative in compose_files:
|
|
122
|
+
if not isinstance(relative, str):
|
|
123
|
+
raise ValueError("deployment metadata has invalid Compose file")
|
|
124
|
+
path = release / _relative(relative)
|
|
125
|
+
if not path.is_file() or path.is_symlink():
|
|
126
|
+
raise ValueError(f"Compose file is unavailable: {relative}")
|
|
127
|
+
argv.extend(("-f", str(path)))
|
|
128
|
+
for profile in profiles:
|
|
129
|
+
if not isinstance(profile, str) or not profile:
|
|
130
|
+
raise ValueError("deployment metadata has invalid Compose profile")
|
|
131
|
+
argv.extend(("--profile", profile))
|
|
132
|
+
argv.extend(("--env-file", str(release / "runtime.env"), *args))
|
|
133
|
+
return argv
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _run_compose(release: Path, metadata: dict[str, object], *args: str) -> bool:
|
|
137
|
+
result = subprocess.run(_compose_argv(release, metadata, *args), cwd=release, check=False)
|
|
138
|
+
return result.returncode == 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _runtime_environment(release: Path) -> dict[str, str]:
|
|
142
|
+
environment = os.environ.copy()
|
|
143
|
+
for line in (release / "runtime.env").read_text(encoding="utf-8").splitlines():
|
|
144
|
+
if not line or line.startswith("#"):
|
|
145
|
+
continue
|
|
146
|
+
key, separator, value = line.partition("=")
|
|
147
|
+
if not separator or re.fullmatch(r"[A-Z_][A-Z0-9_]*", key) is None:
|
|
148
|
+
raise ValueError("runtime environment contains an invalid assignment")
|
|
149
|
+
if value.startswith("'") and value.endswith("'"):
|
|
150
|
+
value = value[1:-1].replace("\\'", "'").replace("\\\\", "\\")
|
|
151
|
+
environment[key] = value
|
|
152
|
+
return environment
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _contract_command(metadata: dict[str, object], name: str) -> tuple[str, ...]:
|
|
156
|
+
stateful = metadata.get("stateful", {})
|
|
157
|
+
if not isinstance(stateful, dict):
|
|
158
|
+
raise ValueError("deployment metadata has invalid stateful policy")
|
|
159
|
+
enabled = stateful.get("enabled", False)
|
|
160
|
+
if not isinstance(enabled, bool):
|
|
161
|
+
raise ValueError("deployment metadata has invalid stateful policy")
|
|
162
|
+
raw = stateful.get(name, [])
|
|
163
|
+
if not isinstance(raw, list) or any(not isinstance(item, str) or not item for item in raw):
|
|
164
|
+
raise ValueError(f"deployment metadata has invalid stateful {name} command")
|
|
165
|
+
if enabled and not raw:
|
|
166
|
+
raise ValueError(f"stateful deployment is missing the {name} command")
|
|
167
|
+
return tuple(raw)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _run_contract(
|
|
171
|
+
release: Path,
|
|
172
|
+
metadata: dict[str, object],
|
|
173
|
+
name: str,
|
|
174
|
+
*,
|
|
175
|
+
check: bool = True,
|
|
176
|
+
) -> bool:
|
|
177
|
+
command = _contract_command(metadata, name)
|
|
178
|
+
if not command:
|
|
179
|
+
return True
|
|
180
|
+
account = pwd.getpwnam("devops")
|
|
181
|
+
environment = _runtime_environment(release)
|
|
182
|
+
environment.update(
|
|
183
|
+
{"HOME": account.pw_dir, "LOGNAME": account.pw_name, "USER": account.pw_name}
|
|
184
|
+
)
|
|
185
|
+
result = subprocess.run(
|
|
186
|
+
("/usr/sbin/runuser", "--user", account.pw_name, "--", *command),
|
|
187
|
+
cwd=release,
|
|
188
|
+
env=environment,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
if check and result.returncode:
|
|
192
|
+
raise subprocess.CalledProcessError(result.returncode, command)
|
|
193
|
+
return result.returncode == 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _validate_exposure(release: Path, metadata: dict[str, object]) -> None:
|
|
197
|
+
rendered = subprocess.run(
|
|
198
|
+
_compose_argv(release, metadata, "config", "--no-env-resolution", "--format", "json"),
|
|
199
|
+
cwd=release,
|
|
200
|
+
check=True,
|
|
201
|
+
capture_output=True,
|
|
202
|
+
text=True,
|
|
203
|
+
)
|
|
204
|
+
payload = json.loads(rendered.stdout)
|
|
205
|
+
services = payload.get("services") if isinstance(payload, dict) else None
|
|
206
|
+
if not isinstance(services, dict):
|
|
207
|
+
raise ValueError("effective Compose model has no services")
|
|
208
|
+
for name, service in services.items():
|
|
209
|
+
if not isinstance(service, dict):
|
|
210
|
+
raise ValueError(f"invalid Compose service: {name}")
|
|
211
|
+
if service.get("network_mode") == "host":
|
|
212
|
+
raise ValueError(f"Compose service uses host networking: {name}")
|
|
213
|
+
if service.get("env_file"):
|
|
214
|
+
raise ValueError(f"Compose service uses an unmanaged environment file: {name}")
|
|
215
|
+
forbidden = {
|
|
216
|
+
"privileged mode": service.get("privileged") is True,
|
|
217
|
+
"host PID namespace": service.get("pid") == "host",
|
|
218
|
+
"host IPC namespace": service.get("ipc") == "host",
|
|
219
|
+
"host user namespace": service.get("userns_mode") == "host",
|
|
220
|
+
"added Linux capabilities": bool(service.get("cap_add")),
|
|
221
|
+
"host devices": bool(service.get("devices")),
|
|
222
|
+
}
|
|
223
|
+
unsafe = [label for label, present in forbidden.items() if present]
|
|
224
|
+
if unsafe:
|
|
225
|
+
raise ValueError(f"Compose service {name} uses unsafe privileges: {', '.join(unsafe)}")
|
|
226
|
+
security_options = service.get("security_opt", [])
|
|
227
|
+
if isinstance(security_options, list) and any(
|
|
228
|
+
"unconfined" in str(option).casefold() for option in security_options
|
|
229
|
+
):
|
|
230
|
+
raise ValueError(f"Compose service disables confinement: {name}")
|
|
231
|
+
volumes = service.get("volumes", [])
|
|
232
|
+
if not isinstance(volumes, list):
|
|
233
|
+
raise ValueError(f"Compose service has invalid volumes: {name}")
|
|
234
|
+
for volume in volumes:
|
|
235
|
+
if not isinstance(volume, dict):
|
|
236
|
+
raise ValueError(f"Compose service has an unresolved volume: {name}")
|
|
237
|
+
if volume.get("type") == "bind":
|
|
238
|
+
raise ValueError(f"Compose service uses a host bind mount: {name}")
|
|
239
|
+
source = str(volume.get("source", ""))
|
|
240
|
+
target = str(volume.get("target", ""))
|
|
241
|
+
if "docker.sock" in source or "docker.sock" in target:
|
|
242
|
+
raise ValueError(f"Compose service mounts the Docker socket: {name}")
|
|
243
|
+
ports = service.get("ports", [])
|
|
244
|
+
if not isinstance(ports, list):
|
|
245
|
+
raise ValueError(f"Compose service has invalid ports: {name}")
|
|
246
|
+
for port in ports:
|
|
247
|
+
if isinstance(port, dict):
|
|
248
|
+
host_ip = port.get("host_ip")
|
|
249
|
+
if host_ip not in {"127.0.0.1", "::1", "localhost"}:
|
|
250
|
+
raise ValueError(f"Compose service publishes a non-loopback port: {name}")
|
|
251
|
+
elif isinstance(port, str) and not port.startswith(("127.0.0.1:", "[::1]:")):
|
|
252
|
+
raise ValueError(f"Compose service publishes a non-loopback port: {name}")
|
|
253
|
+
volumes = payload.get("volumes", {})
|
|
254
|
+
if isinstance(volumes, dict):
|
|
255
|
+
for name, volume in volumes.items():
|
|
256
|
+
if isinstance(volume, dict) and volume.get("driver_opts"):
|
|
257
|
+
raise ValueError(f"Compose volume uses driver options: {name}")
|
|
258
|
+
secrets = payload.get("secrets", {})
|
|
259
|
+
if not isinstance(secrets, dict):
|
|
260
|
+
raise ValueError("Compose model has invalid secrets")
|
|
261
|
+
if secrets:
|
|
262
|
+
raise ValueError("Compose file-backed secrets are not supported")
|
|
263
|
+
configs = payload.get("configs", {})
|
|
264
|
+
if not isinstance(configs, dict):
|
|
265
|
+
raise ValueError("Compose model has invalid configs")
|
|
266
|
+
for name, entry in configs.items():
|
|
267
|
+
if not isinstance(entry, dict) or "file" not in entry:
|
|
268
|
+
continue
|
|
269
|
+
input_path = Path(str(entry["file"])).resolve()
|
|
270
|
+
if (
|
|
271
|
+
release.resolve() not in input_path.parents
|
|
272
|
+
or not input_path.is_file()
|
|
273
|
+
or input_path.is_symlink()
|
|
274
|
+
):
|
|
275
|
+
raise ValueError(f"Compose configs input escapes the release: {name}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _healthy(url: str, *, attempts: int = 12) -> bool:
|
|
279
|
+
for attempt in range(attempts):
|
|
280
|
+
try:
|
|
281
|
+
# urlopen follows redirects and raises HTTPError for any other final status.
|
|
282
|
+
with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310
|
|
283
|
+
if 200 <= response.status < 300:
|
|
284
|
+
return True
|
|
285
|
+
except (OSError, urllib.error.URLError):
|
|
286
|
+
pass
|
|
287
|
+
if attempt + 1 < attempts:
|
|
288
|
+
time.sleep(5)
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _atomic_link(target: Path, destination: Path) -> None:
|
|
293
|
+
temporary = destination.parent / f".{destination.name}-next"
|
|
294
|
+
temporary.unlink(missing_ok=True)
|
|
295
|
+
temporary.symlink_to(target)
|
|
296
|
+
os.replace(temporary, destination)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _release_metadata(release: Path) -> dict[str, object] | None:
|
|
300
|
+
metadata = release / "deployment.json"
|
|
301
|
+
marker = release / ".ldw-deployed"
|
|
302
|
+
if (
|
|
303
|
+
not release.is_dir()
|
|
304
|
+
or release.is_symlink()
|
|
305
|
+
or not metadata.is_file()
|
|
306
|
+
or not marker.is_file()
|
|
307
|
+
):
|
|
308
|
+
return None
|
|
309
|
+
payload = json.loads(metadata.read_text(encoding="utf-8"))
|
|
310
|
+
return payload if isinstance(payload, dict) else None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _current(root: Path) -> Path | None:
|
|
314
|
+
link = root / "current"
|
|
315
|
+
if not link.exists() and not link.is_symlink():
|
|
316
|
+
return None
|
|
317
|
+
if not link.is_symlink():
|
|
318
|
+
raise ValueError("current release pointer is not a symlink")
|
|
319
|
+
release = link.resolve(strict=True)
|
|
320
|
+
if release.parent != (root / "releases").resolve() or _release_metadata(release) is None:
|
|
321
|
+
raise ValueError("current release is not verified")
|
|
322
|
+
return release
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _start(release: Path, metadata: dict[str, object]) -> bool:
|
|
326
|
+
_validate_exposure(release, metadata)
|
|
327
|
+
transport = metadata.get("artifact_transport")
|
|
328
|
+
if transport not in {"registry", "ssh-archive"}:
|
|
329
|
+
raise ValueError("deployment metadata has invalid artifact transport")
|
|
330
|
+
return _run_compose(
|
|
331
|
+
release,
|
|
332
|
+
metadata,
|
|
333
|
+
"up",
|
|
334
|
+
"-d",
|
|
335
|
+
"--wait",
|
|
336
|
+
"--wait-timeout",
|
|
337
|
+
"120",
|
|
338
|
+
"--pull",
|
|
339
|
+
"always" if transport == "registry" else "never",
|
|
340
|
+
"--remove-orphans",
|
|
341
|
+
) and _healthy(_health_url(metadata.get("health_url")))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _stop(release: Path, metadata: dict[str, object]) -> bool:
|
|
345
|
+
return _run_compose(release, metadata, "stop")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def deploy(project: str, build_id: str, metadata_name: str, metadata_sha256: str) -> int:
|
|
349
|
+
project = _component(project)
|
|
350
|
+
build_id = _component(build_id, build=True)
|
|
351
|
+
staged_metadata = _staged(metadata_name)
|
|
352
|
+
metadata = _metadata(staged_metadata, project, build_id, metadata_sha256)
|
|
353
|
+
bundle = _verified_staged(metadata.get("bundle"))
|
|
354
|
+
environment = _verified_staged(metadata.get("environment"))
|
|
355
|
+
provenance = _verified_staged(metadata.get("provenance"))
|
|
356
|
+
sbom = _verified_staged(metadata.get("sbom"))
|
|
357
|
+
image_record = metadata.get("image_archive")
|
|
358
|
+
image_archive = _verified_staged(image_record) if image_record is not None else None
|
|
359
|
+
root = PROJECTS_ROOT / project
|
|
360
|
+
releases = root / "releases"
|
|
361
|
+
release = releases / build_id
|
|
362
|
+
releases.mkdir(parents=True, exist_ok=True, mode=0o750)
|
|
363
|
+
if release.exists():
|
|
364
|
+
raise ValueError("release already exists")
|
|
365
|
+
release.mkdir(mode=0o755)
|
|
366
|
+
previous: Path | None = None
|
|
367
|
+
switched = False
|
|
368
|
+
try:
|
|
369
|
+
_safe_extract(bundle, release)
|
|
370
|
+
shutil.copyfile(environment, release / "runtime.env")
|
|
371
|
+
(release / "runtime.env").chmod(0o600)
|
|
372
|
+
shutil.copyfile(provenance, release / "provenance.json")
|
|
373
|
+
(release / "provenance.json").chmod(0o644)
|
|
374
|
+
shutil.copyfile(sbom, release / "sbom.cdx.json")
|
|
375
|
+
(release / "sbom.cdx.json").chmod(0o644)
|
|
376
|
+
if image_archive is not None:
|
|
377
|
+
subprocess.run([DOCKER, "load", "--input", str(image_archive)], check=True)
|
|
378
|
+
shutil.copyfile(staged_metadata, release / "deployment.json")
|
|
379
|
+
(release / "deployment.json").chmod(0o600)
|
|
380
|
+
previous = _current(root)
|
|
381
|
+
contract_root = previous or release
|
|
382
|
+
_run_contract(contract_root, metadata, "backup")
|
|
383
|
+
_run_contract(release, metadata, "migration")
|
|
384
|
+
if previous is not None:
|
|
385
|
+
_atomic_link(previous, root / "previous")
|
|
386
|
+
_atomic_link(release, root / "current")
|
|
387
|
+
switched = True
|
|
388
|
+
if _start(release, metadata) and _run_contract(release, metadata, "verify"):
|
|
389
|
+
(release / ".ldw-deployed").touch(mode=0o600)
|
|
390
|
+
_prune(root)
|
|
391
|
+
return 0
|
|
392
|
+
_stop(release, metadata)
|
|
393
|
+
_run_contract(release, metadata, "rollback", check=False)
|
|
394
|
+
if previous is not None:
|
|
395
|
+
previous_metadata = _release_metadata(previous)
|
|
396
|
+
_atomic_link(previous, root / "current")
|
|
397
|
+
if previous_metadata is not None:
|
|
398
|
+
_start(previous, previous_metadata)
|
|
399
|
+
else:
|
|
400
|
+
(root / "current").unlink(missing_ok=True)
|
|
401
|
+
shutil.rmtree(release, ignore_errors=True)
|
|
402
|
+
return 6
|
|
403
|
+
except BaseException:
|
|
404
|
+
if switched:
|
|
405
|
+
with suppress(OSError, ValueError, subprocess.SubprocessError):
|
|
406
|
+
_stop(release, metadata)
|
|
407
|
+
with suppress(OSError, ValueError, subprocess.SubprocessError):
|
|
408
|
+
_run_contract(release, metadata, "rollback", check=False)
|
|
409
|
+
if previous is not None:
|
|
410
|
+
_atomic_link(previous, root / "current")
|
|
411
|
+
previous_metadata = _release_metadata(previous)
|
|
412
|
+
if previous_metadata is not None:
|
|
413
|
+
with suppress(OSError, ValueError, subprocess.SubprocessError):
|
|
414
|
+
_start(previous, previous_metadata)
|
|
415
|
+
else:
|
|
416
|
+
(root / "current").unlink(missing_ok=True)
|
|
417
|
+
if not (release / ".ldw-deployed").exists():
|
|
418
|
+
shutil.rmtree(release, ignore_errors=True)
|
|
419
|
+
raise
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def rollback(project: str, build_id: str | None = None) -> int:
|
|
423
|
+
project = _component(project)
|
|
424
|
+
root = PROJECTS_ROOT / project
|
|
425
|
+
if build_id is None:
|
|
426
|
+
previous_link = root / "previous"
|
|
427
|
+
if not previous_link.is_symlink():
|
|
428
|
+
return 3
|
|
429
|
+
previous = previous_link.resolve(strict=True)
|
|
430
|
+
if previous.parent != (root / "releases").resolve():
|
|
431
|
+
raise ValueError("previous release pointer is invalid")
|
|
432
|
+
build_id = previous.name
|
|
433
|
+
build_id = _component(build_id, build=True)
|
|
434
|
+
target = root / "releases" / build_id
|
|
435
|
+
target_metadata = _release_metadata(target)
|
|
436
|
+
if target_metadata is None:
|
|
437
|
+
return 3
|
|
438
|
+
original = _current(root)
|
|
439
|
+
if original == target:
|
|
440
|
+
return 0 if _start(target, target_metadata) else 5
|
|
441
|
+
_atomic_link(target, root / "current")
|
|
442
|
+
try:
|
|
443
|
+
target_ready = _start(target, target_metadata)
|
|
444
|
+
except BaseException:
|
|
445
|
+
if original is not None:
|
|
446
|
+
_atomic_link(original, root / "current")
|
|
447
|
+
else:
|
|
448
|
+
(root / "current").unlink(missing_ok=True)
|
|
449
|
+
raise
|
|
450
|
+
if target_ready:
|
|
451
|
+
if original is not None:
|
|
452
|
+
_atomic_link(original, root / "previous")
|
|
453
|
+
return 0
|
|
454
|
+
_stop(target, target_metadata)
|
|
455
|
+
if original is None:
|
|
456
|
+
(root / "current").unlink(missing_ok=True)
|
|
457
|
+
return 6
|
|
458
|
+
original_metadata = _release_metadata(original)
|
|
459
|
+
_atomic_link(original, root / "current")
|
|
460
|
+
return 6 if original_metadata is not None and _start(original, original_metadata) else 7
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _prune(root: Path) -> None:
|
|
464
|
+
protected = {path.resolve() for path in (root / "current", root / "previous") if path.exists()}
|
|
465
|
+
releases = sorted(
|
|
466
|
+
(path for path in (root / "releases").iterdir() if path.is_dir() and not path.is_symlink()),
|
|
467
|
+
key=lambda path: (path.stat().st_mtime_ns, path.name),
|
|
468
|
+
reverse=True,
|
|
469
|
+
)
|
|
470
|
+
keep = set(protected)
|
|
471
|
+
for release in releases:
|
|
472
|
+
if len(keep) >= 3:
|
|
473
|
+
break
|
|
474
|
+
keep.add(release.resolve())
|
|
475
|
+
for release in releases:
|
|
476
|
+
if release.resolve() not in keep:
|
|
477
|
+
shutil.rmtree(release)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def status(project: str) -> int:
|
|
481
|
+
project = _component(project)
|
|
482
|
+
root = PROJECTS_ROOT / project
|
|
483
|
+
current = _current(root)
|
|
484
|
+
print(
|
|
485
|
+
json.dumps(
|
|
486
|
+
{
|
|
487
|
+
"project": project,
|
|
488
|
+
"current": current.name if current is not None else None,
|
|
489
|
+
"previous": (root / "previous").resolve().name
|
|
490
|
+
if (root / "previous").exists()
|
|
491
|
+
else None,
|
|
492
|
+
},
|
|
493
|
+
sort_keys=True,
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
return 0
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def main() -> int:
|
|
500
|
+
try:
|
|
501
|
+
project = _component(sys.argv[2]) if len(sys.argv) >= 3 else ""
|
|
502
|
+
lock_path = Path("/run/lock") / f"ldw-{project}.lock"
|
|
503
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
504
|
+
with lock_path.open("a+b") as lock:
|
|
505
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
506
|
+
if len(sys.argv) == 6 and sys.argv[1] == "deploy":
|
|
507
|
+
return deploy(project, sys.argv[3], sys.argv[4], sys.argv[5])
|
|
508
|
+
if len(sys.argv) == 3 and sys.argv[1] == "rollback":
|
|
509
|
+
return rollback(project)
|
|
510
|
+
if len(sys.argv) == 4 and sys.argv[1] == "rollback":
|
|
511
|
+
return rollback(project, sys.argv[3])
|
|
512
|
+
if len(sys.argv) == 3 and sys.argv[1] == "status":
|
|
513
|
+
return status(project)
|
|
514
|
+
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
|
515
|
+
print(str(error), file=sys.stderr)
|
|
516
|
+
return 4
|
|
517
|
+
print(
|
|
518
|
+
"usage: ldw-remote-deploy deploy PROJECT BUILD METADATA SHA256 | "
|
|
519
|
+
"rollback PROJECT [BUILD] | status PROJECT",
|
|
520
|
+
file=sys.stderr,
|
|
521
|
+
)
|
|
522
|
+
return 2
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
if __name__ == "__main__":
|
|
526
|
+
raise SystemExit(main())
|
ldw_deploy/commands.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""SSH target and production release commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ldw_deploy.service import SshDeploymentService
|
|
11
|
+
from local_devops_workbench.plugin_api import (
|
|
12
|
+
PLUGIN_API_VERSION,
|
|
13
|
+
CommandContribution,
|
|
14
|
+
CoreHandle,
|
|
15
|
+
render_mapping,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
TARGET_HELP = "Bind and inspect SSH production targets."
|
|
19
|
+
DEPLOY_HELP = "Plan and apply immutable production releases."
|
|
20
|
+
target_app = typer.Typer(no_args_is_help=True)
|
|
21
|
+
deploy_app = typer.Typer(no_args_is_help=True)
|
|
22
|
+
run = asyncio.run
|
|
23
|
+
# Core passes one lazy handle to both registrations; commands read it when they run.
|
|
24
|
+
_handles: list[CoreHandle] = []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_target(handle: CoreHandle) -> CommandContribution:
|
|
28
|
+
_handles[:] = [handle]
|
|
29
|
+
return CommandContribution(
|
|
30
|
+
name="target", app=target_app, help=TARGET_HELP, api_version=PLUGIN_API_VERSION
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_deploy(handle: CoreHandle) -> CommandContribution:
|
|
35
|
+
_handles[:] = [handle]
|
|
36
|
+
return CommandContribution(
|
|
37
|
+
name="deploy", app=deploy_app, help=DEPLOY_HELP, api_version=PLUGIN_API_VERSION
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _handle() -> CoreHandle:
|
|
42
|
+
if not _handles:
|
|
43
|
+
raise RuntimeError("ldw-deploy commands ran before core registered them")
|
|
44
|
+
return _handles[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def deployments() -> SshDeploymentService:
|
|
48
|
+
handle = _handle()
|
|
49
|
+
return SshDeploymentService(handle.paths, handle.projects, handle.runner, handle.store)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ensure_linked_project(project_id: str) -> None:
|
|
53
|
+
_handle().ensure_linked(project_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@target_app.command("bind")
|
|
57
|
+
def target_bind(
|
|
58
|
+
project_id: str,
|
|
59
|
+
target: str,
|
|
60
|
+
ssh_alias: Annotated[str, typer.Option("--ssh-alias")],
|
|
61
|
+
host_key_sha256: Annotated[str, typer.Option("--host-key-sha256")],
|
|
62
|
+
bootstrap_user: Annotated[str, typer.Option("--bootstrap-user")],
|
|
63
|
+
secret_command: Annotated[
|
|
64
|
+
list[str] | None,
|
|
65
|
+
typer.Option(
|
|
66
|
+
"--secret-command",
|
|
67
|
+
help="One argv element for the external JSON secret provider; repeat as needed.",
|
|
68
|
+
),
|
|
69
|
+
] = None,
|
|
70
|
+
registry: Annotated[str | None, typer.Option("--registry")] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Pin and bind an existing SSH host without changing it."""
|
|
73
|
+
_ensure_linked_project(project_id)
|
|
74
|
+
result = run(
|
|
75
|
+
deployments().bind(
|
|
76
|
+
project_id,
|
|
77
|
+
target,
|
|
78
|
+
ssh_alias=ssh_alias,
|
|
79
|
+
host_key_sha256=host_key_sha256,
|
|
80
|
+
bootstrap_user=bootstrap_user,
|
|
81
|
+
secret_command=tuple(secret_command or ()),
|
|
82
|
+
registry=registry,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
render_mapping(
|
|
86
|
+
_handle().output.console,
|
|
87
|
+
f"{project_id}/{target} target bound",
|
|
88
|
+
result,
|
|
89
|
+
subtitle="Host key and machine identity pinned",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@target_app.command("bootstrap")
|
|
94
|
+
def target_bootstrap(project_id: str, target: str) -> None:
|
|
95
|
+
"""Install the restricted deploy account and narrow root helper."""
|
|
96
|
+
_ensure_linked_project(project_id)
|
|
97
|
+
result = run(deployments().bootstrap(project_id, target))
|
|
98
|
+
render_mapping(_handle().output.console, f"{project_id}/{target} target bootstrapped", result)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@target_app.command("status")
|
|
102
|
+
def target_remote_status(
|
|
103
|
+
project_id: str,
|
|
104
|
+
target: str,
|
|
105
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit stable JSON.")] = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Verify the pinned host identity and current release."""
|
|
108
|
+
_ensure_linked_project(project_id)
|
|
109
|
+
result = run(deployments().status(project_id, target))
|
|
110
|
+
if as_json:
|
|
111
|
+
_handle().output.print_json(result)
|
|
112
|
+
else:
|
|
113
|
+
render_mapping(_handle().output.console, f"{project_id}/{target} target", result)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@deploy_app.command("plan")
|
|
117
|
+
def deploy_plan(
|
|
118
|
+
project_id: str,
|
|
119
|
+
target: Annotated[str, typer.Option("--target")],
|
|
120
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit stable JSON.")] = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Create an immutable production deployment plan."""
|
|
123
|
+
_ensure_linked_project(project_id)
|
|
124
|
+
plan = run(deployments().plan(project_id, target))
|
|
125
|
+
payload = plan.model_dump(mode="json")
|
|
126
|
+
if as_json:
|
|
127
|
+
_handle().output.print_json(payload)
|
|
128
|
+
return
|
|
129
|
+
render_mapping(_handle().output.console, f"{project_id}/{target} deployment plan", payload)
|
|
130
|
+
_handle().output.console.print(
|
|
131
|
+
f"Apply exactly: ldw deploy apply {plan.plan_id} --expect-hash {plan.content_hash}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@deploy_app.command("apply")
|
|
136
|
+
def deploy_apply(
|
|
137
|
+
plan_id: str,
|
|
138
|
+
expect_hash: Annotated[str, typer.Option("--expect-hash")],
|
|
139
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit stable JSON.")] = False,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Apply one exact, unchanged production deployment plan."""
|
|
142
|
+
result = run(deployments().apply(plan_id, expect_hash=expect_hash))
|
|
143
|
+
if as_json:
|
|
144
|
+
_handle().output.print_json(result)
|
|
145
|
+
else:
|
|
146
|
+
render_mapping(_handle().output.console, "Production deployment applied", result)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@deploy_app.command("rollback")
|
|
150
|
+
def deploy_rollback(
|
|
151
|
+
project_id: str,
|
|
152
|
+
target: Annotated[str, typer.Option("--target")],
|
|
153
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit stable JSON.")] = False,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Atomically activate the previous verified release."""
|
|
156
|
+
_ensure_linked_project(project_id)
|
|
157
|
+
result = run(deployments().rollback(project_id, target))
|
|
158
|
+
if as_json:
|
|
159
|
+
_handle().output.print_json(result)
|
|
160
|
+
else:
|
|
161
|
+
render_mapping(_handle().output.console, f"{project_id}/{target} rollback", result)
|