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 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)