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.
- vps_deployer/__init__.py +3 -0
- vps_deployer/cli.py +288 -0
- vps_deployer/config.py +109 -0
- vps_deployer/deployment.py +699 -0
- vps_deployer/doctor.py +135 -0
- vps_deployer/expectations.py +91 -0
- vps_deployer/models.py +380 -0
- vps_deployer/nginx.py +53 -0
- vps_deployer/remote.py +141 -0
- vps_deployer/scaffold.py +162 -0
- vps_deployer/systemd.py +62 -0
- vps_deployer-1.2.0.dist-info/METADATA +277 -0
- vps_deployer-1.2.0.dist-info/RECORD +17 -0
- vps_deployer-1.2.0.dist-info/WHEEL +5 -0
- vps_deployer-1.2.0.dist-info/entry_points.txt +2 -0
- vps_deployer-1.2.0.dist-info/licenses/LICENSE +122 -0
- vps_deployer-1.2.0.dist-info/top_level.txt +1 -0
vps_deployer/__init__.py
ADDED
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
|