vps-deployer 1.2.0__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.
@@ -0,0 +1,3 @@
1
+ """vps-deployer."""
2
+
3
+ __version__ = "1.2.0"
vps_deployer/cli.py ADDED
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .config import Repository
10
+ from .deployment import Reconciler, git_metadata, release_id, select_rollback
11
+ from .models import ConfigError
12
+ from .remote import RemoteError, RemoteHost
13
+ from .systemd import timer_name, unit_name
14
+ from .expectations import evaluate_expectations
15
+ from .doctor import ERROR, WARN, diagnose
16
+ from .scaffold import init_app, init_infra
17
+
18
+
19
+ def parser() -> argparse.ArgumentParser:
20
+ p = argparse.ArgumentParser(prog="vps-deployer")
21
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
22
+ p.add_argument("--repo", type=Path, default=None,
23
+ help="infra repository root (default: current directory if it holds hosts/ and "
24
+ "deployments/, else $VPS_DEPLOYER_REPO, else $SERVICE_INFRA_DIR)")
25
+ p.add_argument("-v", "--verbose", action="store_true")
26
+ sub = p.add_subparsers(dest="command", required=True)
27
+ val = sub.add_parser("validate"); val.add_argument("deployment", nargs="?")
28
+ sec = sub.add_parser("secrets", help="inspect secret environment references")
29
+ ss = sec.add_subparsers(dest="secrets_command", required=True)
30
+ ss.add_parser("check", help="list unset secret variables by name").add_argument("deployment", nargs="?")
31
+ host = sub.add_parser("host"); hs = host.add_subparsers(dest="host_command", required=True)
32
+ for name in ("inspect", "onboard"): hs.add_parser(name).add_argument("host")
33
+ ini = sub.add_parser("init", help="scaffold an infra repository, or `init app` for an application")
34
+ ini.add_argument("target", nargs="*", help="[DIR] or: app [DIR]")
35
+ doc = sub.add_parser("doctor", help="offline preflight of an application's .deployer contract")
36
+ doc.add_argument("app", nargs="?", type=Path, default=Path("."))
37
+ lst = sub.add_parser("list", help="list deployments from the manifests (offline)")
38
+ lst.add_argument("--format", choices=("table", "markdown"), default="table")
39
+ for name in ("check", "plan", "apply", "status", "logs", "rollback", "remove"):
40
+ cmd = sub.add_parser(name)
41
+ if name in ("plan", "status"):
42
+ cmd.add_argument("deployment", nargs="?")
43
+ cmd.add_argument("--all", action="store_true", help="every deployment")
44
+ else:
45
+ cmd.add_argument("deployment")
46
+ if name == "plan":
47
+ cmd.add_argument("--exit-code", action="store_true", help="exit 1 when any change is planned")
48
+ if name in ("plan", "apply"): cmd.add_argument("--release-id")
49
+ if name == "apply": cmd.add_argument("--allow-privileged", action="store_true")
50
+ if name == "remove": cmd.add_argument("--allow-privileged", action="store_true")
51
+ if name == "logs":
52
+ cmd.add_argument("--lines", type=int, default=100); cmd.add_argument("--since"); cmd.add_argument("--follow", action="store_true")
53
+ if name == "rollback": cmd.add_argument("--release")
54
+ return p
55
+
56
+
57
+ def resolve_repo_path(explicit: Path | None, cwd: Path | None = None, environ=None) -> Path:
58
+ """Pick the infra repository: --repo, then cwd if it looks like one, then environment."""
59
+ if explicit is not None: return explicit
60
+ environ = os.environ if environ is None else environ
61
+ cwd = Path.cwd() if cwd is None else cwd
62
+ if (cwd / "hosts").is_dir() and (cwd / "deployments").is_dir(): return cwd
63
+ for name in ("VPS_DEPLOYER_REPO", "SERVICE_INFRA_DIR"):
64
+ if environ.get(name): return Path(environ[name])
65
+ return cwd
66
+
67
+
68
+ def _repo(args) -> Repository:
69
+ return Repository.load(resolve_repo_path(args.repo))
70
+
71
+
72
+ def _missing_secrets_message(dep, missing) -> str:
73
+ names = ", ".join(sorted({name for _, name in missing}))
74
+ return f"{dep.name}: secret environment variables not set: {names}"
75
+
76
+
77
+ def _dep(repo, name):
78
+ if name not in repo.deployments: raise ConfigError(f"unknown deployment: {name}")
79
+ return repo.deployments[name]
80
+
81
+
82
+ def _remote(repo, dep, verbose): return RemoteHost(repo.hosts[dep.host], verbose)
83
+
84
+
85
+ def _kind(dep) -> str:
86
+ return "command" if dep.executable else ("timer" if dep.timer else "service")
87
+
88
+
89
+ def _ingress(dep) -> str:
90
+ if dep.http_proxy: return dep.http_proxy.domain
91
+ if dep.healthcheck: return dep.healthcheck["url"].split("/", 3)[2]
92
+ return "-"
93
+
94
+
95
+ def _table(header, rows) -> str:
96
+ widths = [max(len(x) for x in col) for col in zip(header, *rows)] if rows else [len(h) for h in header]
97
+ line = lambda r: " ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip()
98
+ return "\n".join([line(header)] + [line(r) for r in rows])
99
+
100
+
101
+ def render_listing(repo, fmt: str) -> str:
102
+ deps = _sorted_deployments(repo)
103
+ if fmt == "markdown":
104
+ header = ("deployment", "host", "kind", "user", "ingress", "storage", "secret variables")
105
+ lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
106
+ for d in deps:
107
+ storage = ", ".join(f"`{s.path}`" for s in d.storage) or "none"
108
+ secrets = ", ".join(f"`{ref.from_env}`" for ref in d.secrets.values()) or "none"
109
+ cells = (f"`{d.name}`", f"`{d.host}`", _kind(d), f"`{d.user}`", _ingress(d), storage, secrets)
110
+ lines.append("| " + " | ".join(cells) + " |")
111
+ return "\n".join(lines)
112
+ rows = [(d.name, d.host, _kind(d), d.user, _ingress(d), str(len(d.secrets))) for d in deps]
113
+ return _table(("deployment", "host", "kind", "user", "ingress", "secrets"), rows)
114
+
115
+
116
+ def _status_info(repo, dep, remote) -> dict:
117
+ unit = unit_name(dep); supervisor = timer_name(dep) if dep.timer else unit
118
+ rec = Reconciler(repo, dep, remote, "status")
119
+ active = rec.active_release()
120
+ service = (None if dep.executable else remote.run(["systemctl", "is-active", supervisor], sudo=True, check=False))
121
+ desired = release_id(dep)
122
+ manifest = rec.release_manifest() if active else {}
123
+ metadata_source = dep.source if dep.source.is_dir() else dep.source.parent
124
+ desired_commit, _ = git_metadata(metadata_source, desired)
125
+ manifest_ok = manifest.get("deployment.release") == desired and (not desired_commit or manifest.get("deployment.commit") == desired_commit)
126
+ healthy = (dep.executable is not None or service.returncode == 0) and rec._healthy() and manifest_ok
127
+ return dict(active=active, service=service, desired=desired, manifest=manifest,
128
+ desired_commit=desired_commit, manifest_ok=manifest_ok, healthy=healthy)
129
+
130
+
131
+ def _sorted_deployments(repo):
132
+ return sorted(repo.deployments.values(), key=lambda d: (d.host, d.name))
133
+
134
+
135
+ def _status_all(repo, args) -> int:
136
+ rows, failed = [], False
137
+ for dep in _sorted_deployments(repo):
138
+ try:
139
+ info = _status_info(repo, dep, _remote(repo, dep, args.verbose))
140
+ commit = (info["manifest"].get("deployment.commit") or "-")[:8]
141
+ rows.append((dep.name, dep.host, _kind(dep), "healthy" if info["healthy"] else "unhealthy",
142
+ str(info["active"] or "none")[:16], commit))
143
+ failed |= not info["healthy"]
144
+ except (ConfigError, RemoteError, RuntimeError) as exc:
145
+ rows.append((dep.name, dep.host, _kind(dep), "error", "-", str(exc).splitlines()[0][:60])); failed = True
146
+ print(_table(("deployment", "host", "kind", "health", "active release", "commit"), rows))
147
+ return 1 if failed else 0
148
+
149
+
150
+ def _plan_all(repo, args) -> int:
151
+ changed = errors = 0
152
+ for dep in _sorted_deployments(repo):
153
+ print(f"== {dep.name} ({dep.host})")
154
+ try:
155
+ missing = repo.missing_secrets(dep)
156
+ if missing: print(f"warning: {_missing_secrets_message(dep, missing)}", file=sys.stderr)
157
+ actions = Reconciler(repo, dep, _remote(repo, dep, args.verbose), release_id(dep, None)).plan(False)
158
+ print("\n".join(map(str, actions)) if actions else "No changes."); changed += bool(actions)
159
+ except (ConfigError, RemoteError, RuntimeError) as exc:
160
+ print(f"error: {exc}"); errors += 1
161
+ print(f"\n{len(repo.deployments)} deployment(s): {changed} with changes, {errors} error(s)")
162
+ return 2 if errors else (1 if changed and args.exit_code else 0)
163
+
164
+
165
+ def run(args) -> int:
166
+ if args.command == "init":
167
+ target = list(args.target)
168
+ if target and target[0] == "app":
169
+ actions = init_app(Path(target[1]) if len(target) > 1 else Path.cwd())
170
+ else:
171
+ actions = init_infra(Path(target[0]) if target else Path.cwd())
172
+ print("\n".join(actions)); return 0
173
+ if args.command == "doctor":
174
+ try: doctor_repo = Repository.load(resolve_repo_path(args.repo))
175
+ except (ConfigError, OSError, KeyError): doctor_repo = None
176
+ findings = diagnose(args.app, doctor_repo)
177
+ for f in findings:
178
+ print(f"{f.level.upper():5} {f.subject}: {f.detail}")
179
+ errors = sum(f.level == ERROR for f in findings); warns = sum(f.level == WARN for f in findings)
180
+ print(f"\n{errors} error(s), {warns} warning(s)"); return 1 if errors else 0
181
+ repo = _repo(args)
182
+ if args.command == "list":
183
+ print(render_listing(repo, args.format)); return 0
184
+ if args.command in {"plan", "status"}:
185
+ if args.all and args.deployment: raise ConfigError(f"{args.command}: give a deployment or --all, not both")
186
+ if args.all: return _status_all(repo, args) if args.command == "status" else _plan_all(repo, args)
187
+ if not args.deployment: raise ConfigError(f"{args.command}: a deployment name or --all is required")
188
+ if args.command == "validate":
189
+ if args.deployment: _dep(repo, args.deployment)
190
+ print(f"Valid: {args.deployment or f'{len(repo.hosts)} host(s), {len(repo.deployments)} deployment(s)'}"); return 0
191
+ if args.command == "secrets":
192
+ targets = [_dep(repo, args.deployment)] if args.deployment else list(repo.deployments.values())
193
+ status = 0
194
+ for dep in targets:
195
+ missing = repo.missing_secrets(dep)
196
+ if missing:
197
+ status = 1; print(_missing_secrets_message(dep, missing))
198
+ elif dep.secrets:
199
+ print(f"{dep.name}: all {len(dep.secrets)} secret variable(s) set")
200
+ return status
201
+ if args.command == "host":
202
+ if args.host not in repo.hosts: raise ConfigError(f"unknown host: {args.host}")
203
+ host = repo.hosts[args.host]; remote = RemoteHost(host, args.verbose)
204
+ if args.host_command == "inspect":
205
+ checks = [(["uname", "-s"], "linux"), (["uname", "-m"], "architecture"),
206
+ (["systemctl", "--version"], "systemd"),
207
+ (["sh", "-c", "command -v tar curl systemctl"], "commands")]
208
+ print(f"host: {host.name}\nssh: connected")
209
+ for argv, label in checks:
210
+ result = remote.run(argv, check=False); print(f"{label}: {result.stdout.splitlines()[0] if result.returncode == 0 and result.stdout else 'unavailable'}")
211
+ privilege = remote.run(["test", "-e", host.managed_root], sudo=True, check=False)
212
+ print(f"privileged access: {'available' if privilege.returncode in (0, 1) else 'unavailable'}")
213
+ print(f"managed root: {'present' if remote.exists(host.managed_root, True) else 'absent'}"); return 0
214
+ remote.run(["install", "-d", "-o", "root", "-g", "root", "-m", "0755", host.managed_root, "/etc/vps-deployer"], sudo=True)
215
+ print(f"Onboarded {host.name}"); return 0
216
+ dep = _dep(repo, args.deployment); remote = _remote(repo, dep, args.verbose)
217
+ if args.command == "check":
218
+ results = evaluate_expectations(dep, remote)
219
+ print(f"Deployment: {dep.name}\nHost: {dep.host}\n\nHOST EXPECTATIONS")
220
+ for result in results:
221
+ print(f" {'OK' if result.ok else 'FAIL'} {result.subject}: {result.detail}")
222
+ return 0 if all(result.ok for result in results) else 1
223
+ if args.command == "remove":
224
+ rec = Reconciler(repo, dep, remote, "remove")
225
+ actions = rec.remove(args.allow_privileged)
226
+ print(f"Deployment: {dep.name}\nHost: {dep.host}\n")
227
+ print("\n".join(map(str, actions)) if actions else "No changes.")
228
+ return 0
229
+ if args.command in {"plan", "apply"}:
230
+ missing = repo.missing_secrets(dep)
231
+ if missing and args.command == "apply": raise ConfigError(_missing_secrets_message(dep, missing))
232
+ if missing: print(f"warning: {_missing_secrets_message(dep, missing)}; plan shows them as redacted", file=sys.stderr)
233
+ rid = release_id(dep, args.release_id); reconciler = Reconciler(repo, dep, remote, rid)
234
+ if args.command == "plan":
235
+ actions = reconciler.plan(False)
236
+ elif dep.privileged:
237
+ if not args.allow_privileged:
238
+ raise ConfigError("privileged deployment requires --allow-privileged")
239
+ actions = reconciler.apply_privileged()
240
+ else:
241
+ actions = reconciler.apply()
242
+ print(f"Deployment: {dep.name}\nHost: {dep.host}\n")
243
+ print("\n".join(map(str, actions)) if actions else "No changes."); return 0
244
+ unit = unit_name(dep); supervisor = timer_name(dep) if dep.timer else unit; rec = Reconciler(repo, dep, remote, "status")
245
+ if args.command == "status":
246
+ info = _status_info(repo, dep, remote)
247
+ active, service, desired, manifest = info["active"], info["service"], info["desired"], info["manifest"]
248
+ desired_commit, manifest_ok, healthy = info["desired_commit"], info["manifest_ok"], info["healthy"]
249
+ health = "healthy" if healthy else "unhealthy"
250
+ supervisor_label = "executable" if dep.executable else ("timer" if dep.timer else "service")
251
+ supervisor_status = f"/usr/local/bin/{dep.executable}" if dep.executable else (service.stdout.strip() or "unknown")
252
+ print(f"deployment: {dep.name}\nhost: {dep.host}\n{supervisor_label}: {supervisor_status}\n"
253
+ f"desired release: {desired}\nactive release: {active or 'none'}\nhealth: {health}\nuser: {dep.user}")
254
+ print(f"desired commit: {desired_commit or 'unversioned'}\nactive commit: {manifest.get('deployment.commit', 'missing')}\n"
255
+ f"manifest: {'current' if manifest_ok else 'stale or missing'}")
256
+ if dep.timer:
257
+ timer_status = rec.timer_status()
258
+ print(f"last job: {timer_status.get('ExecMainExitTimestamp') or 'never'}\n"
259
+ f"last result: {timer_status.get('Result', 'unknown')}\n"
260
+ f"last exit status: {timer_status.get('ExecMainStatus', 'unknown')}")
261
+ if service is not None and service.returncode: print(remote.run(["systemctl", "status", supervisor, "--no-pager", "--lines=20"], sudo=True, check=False).stdout)
262
+ return 0 if healthy else 1
263
+ if args.command == "logs":
264
+ if dep.executable:
265
+ raise ConfigError("command deployments do not have service logs")
266
+ argv = ["journalctl", "-u", unit, "--no-pager", "-n", str(args.lines)]
267
+ if args.since: argv += ["--since", args.since]
268
+ if args.follow: argv += ["--follow"]
269
+ result = remote.run(argv, sudo=True, check=False); print(result.stdout, end=""); return result.returncode
270
+ listing = remote.run(["find", f"{repo.hosts[dep.host].managed_root}/{dep.name}/releases", "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-print"], sudo=True)
271
+ releases = [Path(path).name for path in listing.stdout.splitlines()]
272
+ current = rec.active_release()
273
+ target = select_rollback(releases, str(current), rec.previous_release(), args.release)
274
+ remote.run(["ln", "-sfn", f"releases/{target}", f"{rec.base}/current"], sudo=True)
275
+ if current:
276
+ remote.run(["ln", "-sfn", f"releases/{current}", f"{rec.base}/previous"], sudo=True)
277
+ if not dep.executable:
278
+ remote.run(["systemctl", "restart", supervisor], sudo=True)
279
+ print(f"Rolled back {dep.name} to {target}"); return 0
280
+
281
+
282
+ def main(argv=None) -> None:
283
+ try: raise SystemExit(run(parser().parse_args(argv)))
284
+ except (ConfigError, RemoteError, RuntimeError) as exc:
285
+ print(f"error: {exc}", file=sys.stderr); raise SystemExit(2)
286
+
287
+
288
+ if __name__ == "__main__": main()
vps_deployer/config.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import os
5
+ import yaml
6
+
7
+ from .models import ConfigError, Deployment, Host, safe_text
8
+
9
+
10
+ class Repository:
11
+ def __init__(self, root: Path):
12
+ self.root = root.resolve()
13
+ self.hosts: dict[str, Host] = {}
14
+ self.deployments: dict[str, Deployment] = {}
15
+ self.globals: dict[str, dict[str, str]] = {}
16
+
17
+ @classmethod
18
+ def load(cls, root: Path) -> "Repository":
19
+ repo = cls(root)
20
+ for path in sorted((repo.root / "hosts").glob("*.yaml")):
21
+ host = Host.parse(_yaml(path), str(path))
22
+ if host.name in repo.hosts:
23
+ raise ConfigError(f"duplicate host: {host.name}")
24
+ repo.hosts[host.name] = host
25
+ for path in sorted((repo.root / "globals").glob("*.yaml")):
26
+ raw = _yaml(path)
27
+ repo.globals[path.stem] = {str(k): str(v) for k, v in raw.items()}
28
+ for path in sorted((repo.root / "deployments").rglob("*.yaml")):
29
+ dep = Deployment.parse(_yaml(path), path)
30
+ if dep.name in repo.deployments:
31
+ raise ConfigError(f"duplicate deployment: {dep.name}")
32
+ repo.deployments[dep.name] = dep
33
+ repo.validate()
34
+ return repo
35
+
36
+ def validate(self) -> None:
37
+ claims: dict[tuple[str, str, str], str] = {}
38
+
39
+ def claim(dep: Deployment, kind: str, value: str) -> None:
40
+ key = (dep.host, kind, value)
41
+ owner = claims.get(key)
42
+ if owner and owner != dep.name:
43
+ raise ConfigError(f"{dep.name}: {kind} {value} conflicts with {owner} on host {dep.host}")
44
+ claims[key] = dep.name
45
+
46
+ for dep in self.deployments.values():
47
+ if dep.host not in self.hosts:
48
+ raise ConfigError(f"{dep.name}: unknown host {dep.host}")
49
+ globals_for_dep = self._global_values(dep)
50
+ for key, ref in dep.environment.items():
51
+ if ref.from_global and ref.from_global not in globals_for_dep:
52
+ raise ConfigError(f"{dep.name}: unknown global reference {ref.from_global} for {key}")
53
+ for storage in dep.storage:
54
+ claim(dep, "storage path", storage.path)
55
+ if dep.healthcheck:
56
+ port = dep.healthcheck["url"].split(":", 2)[2].split("/", 1)[0]
57
+ claim(dep, "loopback port", port)
58
+ if dep.http_proxy:
59
+ claim(dep, "proxy name", dep.http_proxy.name)
60
+ claim(dep, "proxy domain", dep.http_proxy.domain)
61
+ port = dep.http_proxy.upstream.rsplit(":", 1)[1]
62
+ claim(dep, "loopback port", port)
63
+
64
+ def _global_values(self, dep: Deployment) -> dict[str, str]:
65
+ # Prefer a globals file matching the suffix (foo-prod -> prod), then host, then merge all if unique.
66
+ candidates = [dep.name.rsplit("-", 1)[-1], dep.host]
67
+ for candidate in candidates:
68
+ if candidate in self.globals:
69
+ return self.globals[candidate]
70
+ merged: dict[str, str] = {}
71
+ for values in self.globals.values():
72
+ merged.update(values)
73
+ return merged
74
+
75
+ @staticmethod
76
+ def missing_secrets(dep: Deployment) -> list[tuple[str, str]]:
77
+ """Return (secret key, environment variable name) pairs not set locally.
78
+
79
+ Only names are reported; values are never read into the result.
80
+ """
81
+ return sorted((key, str(ref.from_env)) for key, ref in dep.secrets.items()
82
+ if os.environ.get(str(ref.from_env)) is None)
83
+
84
+ def resolve_environment(self, dep: Deployment, require_secrets: bool) -> tuple[dict[str, str], set[str]]:
85
+ result: dict[str, str] = {}
86
+ secret_keys: set[str] = set()
87
+ globals_ = self._global_values(dep)
88
+ for key, ref in dep.environment.items():
89
+ value = ref.literal if ref.literal is not None else globals_[str(ref.from_global)]
90
+ result[key] = safe_text(value, f"{dep.name}: environment.{key}")
91
+ for key, ref in dep.secrets.items():
92
+ secret_keys.add(key)
93
+ value = os.environ.get(str(ref.from_env))
94
+ if value is None:
95
+ if require_secrets:
96
+ raise ConfigError(f"{dep.name}: required secret environment variable is not set for {key}")
97
+ value = "<secret>"
98
+ result[key] = safe_text(value, f"{dep.name}: secrets.{key}")
99
+ return result, secret_keys
100
+
101
+
102
+ def _yaml(path: Path) -> dict:
103
+ try:
104
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
105
+ except yaml.YAMLError as exc:
106
+ raise ConfigError(f"{path}: invalid YAML") from exc
107
+ if not isinstance(data, dict):
108
+ raise ConfigError(f"{path}: expected a mapping")
109
+ return data