veltro-cli 0.15.7__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.
- veltro_cli/__init__.py +6 -0
- veltro_cli/__main__.py +7 -0
- veltro_cli/cli.py +478 -0
- veltro_cli/lib/__init__.py +3 -0
- veltro_cli/lib/agent.py +37 -0
- veltro_cli/lib/backup.py +139 -0
- veltro_cli/lib/compose.py +276 -0
- veltro_cli/lib/datasets.py +611 -0
- veltro_cli/lib/demo.py +421 -0
- veltro_cli/lib/demo_replay.py +794 -0
- veltro_cli/lib/doctor.py +99 -0
- veltro_cli/lib/env_init.py +93 -0
- veltro_cli/lib/errors.py +40 -0
- veltro_cli/lib/host.py +346 -0
- veltro_cli/lib/install.py +400 -0
- veltro_cli/lib/k8s.py +57 -0
- veltro_cli/lib/paths.py +42 -0
- veltro_cli/lib/products.py +54 -0
- veltro_cli/lib/release.py +88 -0
- veltro_cli/lib/status.py +170 -0
- veltro_cli/lib/suite_http.py +227 -0
- veltro_cli/lib/support.py +35 -0
- veltro_cli/lib/tls.py +113 -0
- veltro_cli/lib/toml_config.py +130 -0
- veltro_cli/lib/upgrade.py +379 -0
- veltro_cli/py.typed +0 -0
- veltro_cli-0.15.7.dist-info/METADATA +40 -0
- veltro_cli-0.15.7.dist-info/RECORD +31 -0
- veltro_cli-0.15.7.dist-info/WHEEL +5 -0
- veltro_cli-0.15.7.dist-info/entry_points.txt +2 -0
- veltro_cli-0.15.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""veltro install — materialise a release directory and boot it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import shutil
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from veltro_cli.lib.compose import ComposeClient, RunResult
|
|
14
|
+
from veltro_cli.lib.env_init import init_env
|
|
15
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
16
|
+
from veltro_cli.lib.host import check_compose, check_disk, check_docker, check_host_minimums
|
|
17
|
+
from veltro_cli.lib.products import read_env_file, resolve_selection
|
|
18
|
+
from veltro_cli.lib.release import latest_release_version, release_tag
|
|
19
|
+
from veltro_cli.lib.toml_config import TLS_MODES, VELTRO_TOML, VeltroConfig, read_config, write_config
|
|
20
|
+
from veltro_cli.lib.tls import tls_compose_env, validate_tls_public_url, write_caddyfile
|
|
21
|
+
|
|
22
|
+
# Files copied from a source checkout into an install directory.
|
|
23
|
+
INSTALL_TREE = (
|
|
24
|
+
"cli",
|
|
25
|
+
"docker-compose.yml",
|
|
26
|
+
"versions.env",
|
|
27
|
+
".env.example",
|
|
28
|
+
"Makefile",
|
|
29
|
+
"compose",
|
|
30
|
+
"nginx",
|
|
31
|
+
"scripts",
|
|
32
|
+
"docs",
|
|
33
|
+
"conformance",
|
|
34
|
+
"observability",
|
|
35
|
+
# Demo sandbox corpora (`veltro demo seed --dataset …`, Apache-2.0).
|
|
36
|
+
"content",
|
|
37
|
+
"README.md",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _copy_tree(src: Path, dst: Path) -> None:
|
|
42
|
+
if src.resolve() == dst.resolve():
|
|
43
|
+
return
|
|
44
|
+
if src.is_dir():
|
|
45
|
+
if dst.exists():
|
|
46
|
+
shutil.rmtree(dst)
|
|
47
|
+
shutil.copytree(src, dst, symlinks=True)
|
|
48
|
+
elif src.is_file():
|
|
49
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
shutil.copy2(src, dst)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def materialise_from_checkout(source: Path, dest: Path) -> None:
|
|
54
|
+
source = source.resolve()
|
|
55
|
+
dest = dest.resolve()
|
|
56
|
+
missing = [name for name in ("docker-compose.yml", "versions.env", ".env.example") if not (source / name).exists()]
|
|
57
|
+
if missing:
|
|
58
|
+
raise VeltroCliError(
|
|
59
|
+
f"source checkout incomplete: missing {', '.join(missing)}",
|
|
60
|
+
remedy="point --source at a full Veltro repository checkout",
|
|
61
|
+
)
|
|
62
|
+
if source == dest:
|
|
63
|
+
return
|
|
64
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
for name in INSTALL_TREE:
|
|
66
|
+
src = source / name
|
|
67
|
+
if not src.exists():
|
|
68
|
+
continue
|
|
69
|
+
_copy_tree(src, dest / name)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def materialise_from_tarball(
|
|
73
|
+
url: str,
|
|
74
|
+
dest: Path,
|
|
75
|
+
*,
|
|
76
|
+
opener=urllib.request.urlopen,
|
|
77
|
+
expected_sha256: str | None = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
with tempfile.TemporaryDirectory(prefix="veltro-install-") as tmp:
|
|
81
|
+
tarball = Path(tmp) / "release.tar.gz"
|
|
82
|
+
digest = hashlib.sha256()
|
|
83
|
+
with opener(url) as resp, tarball.open("wb") as fh:
|
|
84
|
+
while chunk := resp.read(1024 * 1024):
|
|
85
|
+
digest.update(chunk)
|
|
86
|
+
fh.write(chunk)
|
|
87
|
+
actual_sha256 = digest.hexdigest()
|
|
88
|
+
if expected_sha256 is not None and actual_sha256 != expected_sha256.removeprefix("sha256:"):
|
|
89
|
+
raise VeltroCliError(
|
|
90
|
+
f"release archive digest mismatch: got sha256:{actual_sha256}",
|
|
91
|
+
remedy="do not install; use the digest published with the immutable release",
|
|
92
|
+
)
|
|
93
|
+
with tarfile.open(tarball, "r:*") as tar:
|
|
94
|
+
# Safe extract: only regular files/dirs, no absolute paths.
|
|
95
|
+
for member in tar.getmembers():
|
|
96
|
+
name = member.name
|
|
97
|
+
if (
|
|
98
|
+
name.startswith("/")
|
|
99
|
+
or ".." in Path(name).parts
|
|
100
|
+
or not (member.isfile() or member.isdir())
|
|
101
|
+
):
|
|
102
|
+
raise VeltroCliError(
|
|
103
|
+
f"refusing unsafe tarball member {name!r}",
|
|
104
|
+
remedy="use an official Veltro release asset",
|
|
105
|
+
)
|
|
106
|
+
tar.extractall(Path(tmp) / "extracted")
|
|
107
|
+
extracted = Path(tmp) / "extracted"
|
|
108
|
+
# Tarballs often wrap a single top-level directory.
|
|
109
|
+
children = [p for p in extracted.iterdir()]
|
|
110
|
+
root = children[0] if len(children) == 1 and children[0].is_dir() else extracted
|
|
111
|
+
materialise_from_checkout(root, dest)
|
|
112
|
+
return actual_sha256
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def set_public_url(env_path: Path, public_url: str) -> None:
|
|
116
|
+
if not env_path.is_file():
|
|
117
|
+
return
|
|
118
|
+
lines = []
|
|
119
|
+
found = False
|
|
120
|
+
for line in env_path.read_text(encoding="utf-8").splitlines(keepends=True):
|
|
121
|
+
if line.startswith("PUBLIC_BASE_URL="):
|
|
122
|
+
lines.append(f"PUBLIC_BASE_URL={public_url}\n")
|
|
123
|
+
found = True
|
|
124
|
+
else:
|
|
125
|
+
lines.append(line if line.endswith("\n") else line + "\n")
|
|
126
|
+
if not found:
|
|
127
|
+
lines.append(f"PUBLIC_BASE_URL={public_url}\n")
|
|
128
|
+
env_path.write_text("".join(lines), encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _public_url(env_path: Path) -> str | None:
|
|
132
|
+
return read_env_file(env_path).get("PUBLIC_BASE_URL") or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def install(
|
|
136
|
+
*,
|
|
137
|
+
products: str | None = None,
|
|
138
|
+
directory: str | Path = "./veltro",
|
|
139
|
+
public_url: str | None = None,
|
|
140
|
+
tls_mode: str = "off",
|
|
141
|
+
yes: bool = False,
|
|
142
|
+
source: Path | None = None,
|
|
143
|
+
release_url: str | None = None,
|
|
144
|
+
release_sha256: str | None = None,
|
|
145
|
+
suite_version: str | None = None,
|
|
146
|
+
monitoring: bool = False,
|
|
147
|
+
client_factory: Callable[..., ComposeClient] | None = None,
|
|
148
|
+
skip_up: bool = False,
|
|
149
|
+
stream=None,
|
|
150
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
151
|
+
) -> Path:
|
|
152
|
+
if products is not None:
|
|
153
|
+
from veltro_cli.lib.products import parse_products
|
|
154
|
+
|
|
155
|
+
parse_products(products)
|
|
156
|
+
if tls_mode not in TLS_MODES:
|
|
157
|
+
raise VeltroCliError(f"unknown tls mode {tls_mode!r}", remedy=f"choose one of: {', '.join(TLS_MODES)}")
|
|
158
|
+
if tls_mode != "off" and public_url is not None:
|
|
159
|
+
validate_tls_public_url(public_url)
|
|
160
|
+
|
|
161
|
+
dest = Path(directory).expanduser().resolve()
|
|
162
|
+
# Refuse legacy product-local installations before replacing any release files.
|
|
163
|
+
read_config(dest / VELTRO_TOML)
|
|
164
|
+
# A named release is validated before anything is written; the newest one
|
|
165
|
+
# is looked up only when an official release is actually fetched.
|
|
166
|
+
tag = release_tag(suite_version) if suite_version else None
|
|
167
|
+
if source is not None and release_url is not None:
|
|
168
|
+
raise VeltroCliError("choose either --source or --release-url, not both")
|
|
169
|
+
if source is not None:
|
|
170
|
+
materialise_from_checkout(source, dest)
|
|
171
|
+
elif release_url is not None:
|
|
172
|
+
if release_sha256 is None:
|
|
173
|
+
raise VeltroCliError(
|
|
174
|
+
"custom --release-url requires --release-sha256",
|
|
175
|
+
remedy="supply the publisher's lowercase SHA-256 digest",
|
|
176
|
+
)
|
|
177
|
+
materialise_from_tarball(
|
|
178
|
+
release_url,
|
|
179
|
+
dest,
|
|
180
|
+
opener=opener,
|
|
181
|
+
expected_sha256=release_sha256,
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
# Official installs are bound to the release-gated manifest before any
|
|
185
|
+
# bytes replace the destination. A checkout is used only with --source.
|
|
186
|
+
from veltro_cli.lib.upgrade import (
|
|
187
|
+
load_release_manifest,
|
|
188
|
+
release_urls,
|
|
189
|
+
verify_staged_release,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if tag is None:
|
|
193
|
+
tag = release_tag(latest_release_version(opener=opener))
|
|
194
|
+
manifest = load_release_manifest(tag, opener=opener)
|
|
195
|
+
_, official_url = release_urls(tag)
|
|
196
|
+
with tempfile.TemporaryDirectory(prefix="veltro-install-stage-") as tmp:
|
|
197
|
+
staged = Path(tmp)
|
|
198
|
+
materialise_from_tarball(official_url, staged, opener=opener)
|
|
199
|
+
verify_staged_release(staged, manifest)
|
|
200
|
+
materialise_from_checkout(staged, dest)
|
|
201
|
+
if tls_mode == "custom":
|
|
202
|
+
missing_certificates = [
|
|
203
|
+
str(dest / "tls" / name)
|
|
204
|
+
for name in ("fullchain.pem", "privkey.pem")
|
|
205
|
+
if not (dest / "tls" / name).is_file()
|
|
206
|
+
]
|
|
207
|
+
if missing_certificates:
|
|
208
|
+
raise VeltroCliError(
|
|
209
|
+
"custom TLS certificate material is missing: "
|
|
210
|
+
+ ", ".join(missing_certificates),
|
|
211
|
+
remedy="copy the PEM files into DIR/tls and re-run install --tls custom",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
env_path = dest / ".env"
|
|
215
|
+
if not env_path.is_file():
|
|
216
|
+
init_env(dest, force=False)
|
|
217
|
+
explicit_products = products
|
|
218
|
+
selected = resolve_selection(dest, products)
|
|
219
|
+
checks = [check_docker(), check_compose(), check_disk(dest), *check_host_minimums()]
|
|
220
|
+
for check in checks:
|
|
221
|
+
if check.status == "fail":
|
|
222
|
+
raise VeltroCliError(f"{check.name}: {check.detail}", remedy=check.remedy)
|
|
223
|
+
if check.status == "warn" and stream is not None:
|
|
224
|
+
print(f"[WARN] {check.name}: {check.detail}", file=stream)
|
|
225
|
+
replay_dir = dest / ".suite" / "vf-agent" / "demo"
|
|
226
|
+
replay_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
227
|
+
replay_dir.chmod(0o755)
|
|
228
|
+
# --yes confirms non-interactive operations; it never rotates credentials.
|
|
229
|
+
existing_public_url = _public_url(env_path)
|
|
230
|
+
if public_url is not None:
|
|
231
|
+
set_public_url(env_path, public_url)
|
|
232
|
+
effective_public_url = public_url or existing_public_url or "http://localhost"
|
|
233
|
+
if tls_mode != "off":
|
|
234
|
+
validate_tls_public_url(effective_public_url)
|
|
235
|
+
|
|
236
|
+
# CHAD_INGEST_BIND_ADDRESS guard lives in CHAD; we only surface the value.
|
|
237
|
+
# Identity runs for every integrated selection, including core only.
|
|
238
|
+
_validate_identity(dest, selected)
|
|
239
|
+
if explicit_products is not None and "VELTRO_PRODUCTS" in read_env_file(env_path):
|
|
240
|
+
# Otherwise the env selector would undo this install on the next command.
|
|
241
|
+
_remove_env_key(env_path, "VELTRO_PRODUCTS")
|
|
242
|
+
|
|
243
|
+
installed_version = f"source:{source.resolve()}" if source is not None else tag or ""
|
|
244
|
+
cfg = VeltroConfig(
|
|
245
|
+
products=",".join(selected),
|
|
246
|
+
public_url=effective_public_url,
|
|
247
|
+
tls_mode=tls_mode,
|
|
248
|
+
dir=str(dest),
|
|
249
|
+
installed_suite_version=installed_version,
|
|
250
|
+
monitoring=monitoring,
|
|
251
|
+
)
|
|
252
|
+
write_config(dest / VELTRO_TOML, cfg)
|
|
253
|
+
|
|
254
|
+
if tls_mode != "off":
|
|
255
|
+
write_caddyfile(
|
|
256
|
+
dest,
|
|
257
|
+
tls_mode=tls_mode,
|
|
258
|
+
public_url=effective_public_url,
|
|
259
|
+
products=selected,
|
|
260
|
+
)
|
|
261
|
+
# Custom certificate material was validated before config or generated
|
|
262
|
+
# files were written, so a refusal leaves no partial TLS configuration.
|
|
263
|
+
# Stash TLS env into .env so compose interpolation sees it. Move the
|
|
264
|
+
# plaintext origin to loopback so Caddy is the only public listener.
|
|
265
|
+
tls_env = tls_compose_env(tls_mode, effective_public_url)
|
|
266
|
+
port_key = "VELTRO_HTTP_PORT"
|
|
267
|
+
default_port = "8088"
|
|
268
|
+
existing_port = _env_value(dest / ".env", port_key)
|
|
269
|
+
port = (existing_port or default_port).rsplit(":", 1)[-1]
|
|
270
|
+
tls_http_port = _env_value(dest / ".env", "VELTRO_TLS_HTTP_PORT") or "80"
|
|
271
|
+
tls_env["VELTRO_TLS_HTTP_PORT"] = tls_http_port.rsplit(":", 1)[-1]
|
|
272
|
+
if port == tls_env["VELTRO_TLS_HTTP_PORT"]:
|
|
273
|
+
port = default_port
|
|
274
|
+
tls_env[port_key] = f"127.0.0.1:{port}"
|
|
275
|
+
_merge_env(dest / ".env", tls_env)
|
|
276
|
+
if skip_up:
|
|
277
|
+
return dest
|
|
278
|
+
|
|
279
|
+
factory = client_factory or ComposeClient
|
|
280
|
+
client = factory(
|
|
281
|
+
dest, products=selected, tls_mode=tls_mode, monitoring=monitoring,
|
|
282
|
+
)
|
|
283
|
+
result = start_stack(client)
|
|
284
|
+
if result.returncode != 0:
|
|
285
|
+
raise VeltroCliError(
|
|
286
|
+
f"failed to start stack: {result.stderr.strip() or result.stdout.strip()}",
|
|
287
|
+
remedy="run `veltro doctor` and inspect `veltro logs`",
|
|
288
|
+
)
|
|
289
|
+
return dest
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def start_stack(client: ComposeClient, *compose_args: str) -> RunResult:
|
|
293
|
+
"""Start identity, project its live signing ring into .env, then everything.
|
|
294
|
+
|
|
295
|
+
Products verify request assertions against the public ring in .env, so
|
|
296
|
+
whatever ring identity holds (a fresh one, or one a restore brought back)
|
|
297
|
+
is projected before they start. Returns the final ``compose up`` result.
|
|
298
|
+
"""
|
|
299
|
+
result = client.invoke(["up", "-d", "--wait", "veltro"])
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
raise VeltroCliError(
|
|
302
|
+
f"failed to start identity: {result.stderr.strip() or result.stdout.strip()}",
|
|
303
|
+
remedy="run `veltro doctor` and `veltro logs`",
|
|
304
|
+
)
|
|
305
|
+
_materialise_assertion_authority(client.root)
|
|
306
|
+
return client.up(*compose_args)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _validate_identity(root: Path, products: tuple[str, ...] | None = None) -> None:
|
|
312
|
+
import sys
|
|
313
|
+
|
|
314
|
+
scripts = root / "scripts"
|
|
315
|
+
sys.path.insert(0, str(scripts))
|
|
316
|
+
try:
|
|
317
|
+
import validate_identity_credentials as vic # type: ignore
|
|
318
|
+
except ImportError as exc:
|
|
319
|
+
raise VeltroCliError(
|
|
320
|
+
"identity credential validator is missing from the install",
|
|
321
|
+
remedy="restore scripts/validate_identity_credentials.py from the release",
|
|
322
|
+
) from exc
|
|
323
|
+
env_file = root / ".env"
|
|
324
|
+
problems = (
|
|
325
|
+
vic.missing(env_file, {}, products=products)
|
|
326
|
+
+ vic.dsn_problems(env_file, {})
|
|
327
|
+
+ vic.session_secret_problems(env_file, {})
|
|
328
|
+
+ vic.signing_passphrase_problems(env_file, {})
|
|
329
|
+
)
|
|
330
|
+
if problems:
|
|
331
|
+
raise VeltroCliError(
|
|
332
|
+
"identity credentials incomplete: " + "; ".join(problems[:5]),
|
|
333
|
+
remedy="ensure VELTRO_IDENTITY_* secrets are set (re-run install or edit .env)",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _materialise_assertion_authority(root: Path) -> None:
|
|
338
|
+
script = root / "scripts" / "materialize_suite_assertion_authority.py"
|
|
339
|
+
if not script.is_file():
|
|
340
|
+
return
|
|
341
|
+
import runpy
|
|
342
|
+
import sys
|
|
343
|
+
|
|
344
|
+
argv = sys.argv[:]
|
|
345
|
+
try:
|
|
346
|
+
sys.argv = [
|
|
347
|
+
str(script),
|
|
348
|
+
"--env-file",
|
|
349
|
+
str(root / ".env"),
|
|
350
|
+
"--write-env",
|
|
351
|
+
str(root / ".env"),
|
|
352
|
+
"--compose-file",
|
|
353
|
+
str(root / "docker-compose.yml"),
|
|
354
|
+
"--audience",
|
|
355
|
+
"chad",
|
|
356
|
+
"--audience",
|
|
357
|
+
"warden",
|
|
358
|
+
"--audience",
|
|
359
|
+
"vectorflow",
|
|
360
|
+
]
|
|
361
|
+
runpy.run_path(str(script), run_name="__main__")
|
|
362
|
+
except SystemExit as exc:
|
|
363
|
+
if exc.code not in (0, None):
|
|
364
|
+
raise VeltroCliError(
|
|
365
|
+
f"materialize_suite_assertion_authority failed (exit {exc.code})",
|
|
366
|
+
remedy="inspect identity logs and re-run `veltro up`",
|
|
367
|
+
) from exc
|
|
368
|
+
finally:
|
|
369
|
+
sys.argv = argv
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _env_value(env_path: Path, key: str) -> str | None:
|
|
373
|
+
return read_env_file(env_path).get(key)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _remove_env_key(env_path: Path, key: str) -> None:
|
|
377
|
+
lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
378
|
+
kept = [line for line in lines if line.partition("=")[0].strip() != key]
|
|
379
|
+
env_path.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _merge_env(env_path: Path, values: dict[str, str]) -> None:
|
|
383
|
+
"""Upsert KEY=VALUE lines without moving comments or unrelated values."""
|
|
384
|
+
lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.is_file() else []
|
|
385
|
+
seen: set[str] = set()
|
|
386
|
+
out: list[str] = []
|
|
387
|
+
for line in lines:
|
|
388
|
+
if not line.strip() or line.lstrip().startswith("#") or "=" not in line:
|
|
389
|
+
out.append(line)
|
|
390
|
+
continue
|
|
391
|
+
key, _, _value = line.partition("=")
|
|
392
|
+
if key in values:
|
|
393
|
+
out.append(f"{key}={values[key]}")
|
|
394
|
+
seen.add(key)
|
|
395
|
+
else:
|
|
396
|
+
out.append(line)
|
|
397
|
+
for key, value in values.items():
|
|
398
|
+
if key not in seen:
|
|
399
|
+
out.append(f"{key}={value}")
|
|
400
|
+
env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
veltro_cli/lib/k8s.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""veltro k8s render — helm template helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
11
|
+
from veltro_cli.lib.products import PRODUCTS
|
|
12
|
+
|
|
13
|
+
# Chart value key for each product's subchart.
|
|
14
|
+
PRODUCT_CHARTS = {"vectorflow": "vectorflow-server", "chad": "chad", "warden": "warden"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def selection_values(products: Sequence[str]) -> list[str]:
|
|
18
|
+
"""Helm flags for core plus exactly the selected products."""
|
|
19
|
+
argv = ["--set", "veltro-gateway.enabled=true", "--set", "veltro-identity.enabled=true"]
|
|
20
|
+
for product in PRODUCTS:
|
|
21
|
+
enabled = "true" if product in products else "false"
|
|
22
|
+
argv.extend(["--set", f"{PRODUCT_CHARTS[product]}.enabled={enabled}"])
|
|
23
|
+
# --set-json: plain --set turns `{}` into [""], not an empty list.
|
|
24
|
+
argv.extend(["--set-json", f"global.products={json.dumps([p for p in PRODUCTS if p in products], separators=(',', ':'))}"])
|
|
25
|
+
return argv
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render(
|
|
29
|
+
*,
|
|
30
|
+
products: Sequence[str],
|
|
31
|
+
values: Sequence[str] = (),
|
|
32
|
+
chart: str = "oci://ghcr.io/veltrosecurity/charts/veltro",
|
|
33
|
+
release_name: str = "veltro",
|
|
34
|
+
dry_run: bool = False,
|
|
35
|
+
) -> str:
|
|
36
|
+
helm = shutil.which("helm")
|
|
37
|
+
if not helm:
|
|
38
|
+
cmd = f"helm template {release_name} {chart}"
|
|
39
|
+
for v in values:
|
|
40
|
+
cmd += f" -f {v}"
|
|
41
|
+
raise VeltroCliError(
|
|
42
|
+
"helm not found on PATH",
|
|
43
|
+
remedy=f"install helm 3.x, then run: {cmd}",
|
|
44
|
+
)
|
|
45
|
+
argv = [helm, "template", release_name, chart]
|
|
46
|
+
argv.extend(selection_values(products))
|
|
47
|
+
for v in values:
|
|
48
|
+
argv.extend(["-f", v])
|
|
49
|
+
if dry_run:
|
|
50
|
+
return " ".join(argv)
|
|
51
|
+
completed = subprocess.run(argv, capture_output=True, text=True, check=False)
|
|
52
|
+
if completed.returncode != 0:
|
|
53
|
+
raise VeltroCliError(
|
|
54
|
+
completed.stderr.strip() or "helm template failed",
|
|
55
|
+
remedy="check chart values and network access to ghcr.io",
|
|
56
|
+
)
|
|
57
|
+
return completed.stdout
|
veltro_cli/lib/paths.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Resolve install / checkout directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# When running from a source checkout, the package lives at <root>/cli/veltro_cli.
|
|
9
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
10
|
+
_CLI_DIR = _PACKAGE_DIR.parent # veltro_cli/
|
|
11
|
+
_PKG_ROOT = _CLI_DIR.parent # cli/
|
|
12
|
+
# Repo root when developing from the monorepo; install dir otherwise.
|
|
13
|
+
DEFAULT_INSTALL_DIR = Path.cwd() / "veltro"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_repo_root(start: Path | None = None) -> Path | None:
|
|
17
|
+
"""Walk up looking for versions.env + docker-compose.yml (a Veltro checkout)."""
|
|
18
|
+
cur = (start or Path.cwd()).resolve()
|
|
19
|
+
for candidate in [cur, *cur.parents]:
|
|
20
|
+
if (candidate / "versions.env").is_file() and (candidate / "docker-compose.yml").is_file():
|
|
21
|
+
return candidate
|
|
22
|
+
# Source tree layout: cli/ is under the repo root.
|
|
23
|
+
monorepo = _PKG_ROOT.parent
|
|
24
|
+
if (monorepo / "versions.env").is_file() and (monorepo / "docker-compose.yml").is_file():
|
|
25
|
+
return monorepo
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_dir(explicit: str | Path | None = None) -> Path:
|
|
30
|
+
if explicit is not None:
|
|
31
|
+
return Path(explicit).expanduser().resolve()
|
|
32
|
+
env = os.environ.get("VELTRO_DIR")
|
|
33
|
+
if env:
|
|
34
|
+
return Path(env).expanduser().resolve()
|
|
35
|
+
found = find_repo_root()
|
|
36
|
+
if found is not None:
|
|
37
|
+
return found
|
|
38
|
+
return DEFAULT_INSTALL_DIR.resolve()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def enrollment_token_dir(root: Path) -> Path:
|
|
42
|
+
return (root / ".suite" / "vf-agent").resolve()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Installed-product selection shared by CLI commands and host preflight."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
8
|
+
from veltro_cli.lib.toml_config import VELTRO_TOML, read_config
|
|
9
|
+
|
|
10
|
+
PRODUCTS = ("vectorflow", "chad", "warden")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_env_file(path: Path) -> dict[str, str]:
|
|
14
|
+
"""Read Compose-style assignments; the last assignment to a key wins."""
|
|
15
|
+
values: dict[str, str] = {}
|
|
16
|
+
if path.is_file():
|
|
17
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
18
|
+
line = raw.strip()
|
|
19
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
20
|
+
continue
|
|
21
|
+
key, value = line.split("=", 1)
|
|
22
|
+
value = value.strip()
|
|
23
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
24
|
+
value = value[1:-1]
|
|
25
|
+
values[key.strip()] = value
|
|
26
|
+
return values
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_products(value: str) -> tuple[str, ...]:
|
|
30
|
+
if not value.strip():
|
|
31
|
+
return ()
|
|
32
|
+
names = [name.strip() for name in value.split(",")]
|
|
33
|
+
seen: set[str] = set()
|
|
34
|
+
for name in names:
|
|
35
|
+
if name not in PRODUCTS:
|
|
36
|
+
raise VeltroCliError(
|
|
37
|
+
f"unknown VELTRO_PRODUCTS product {name!r}",
|
|
38
|
+
remedy=f"choose a comma-separated subset of {', '.join(PRODUCTS)}",
|
|
39
|
+
)
|
|
40
|
+
if name in seen:
|
|
41
|
+
raise VeltroCliError(f"duplicate VELTRO_PRODUCTS product {name!r}")
|
|
42
|
+
seen.add(name)
|
|
43
|
+
return tuple(name for name in PRODUCTS if name in seen)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_selection(root: Path, explicit_products: str | None = None) -> tuple[str, ...]:
|
|
47
|
+
"""Explicit selection > present .env selector > saved selection > all products."""
|
|
48
|
+
cfg = read_config(root / VELTRO_TOML)
|
|
49
|
+
if explicit_products is not None:
|
|
50
|
+
return parse_products(explicit_products)
|
|
51
|
+
env = read_env_file(root / ".env")
|
|
52
|
+
if "VELTRO_PRODUCTS" in env:
|
|
53
|
+
return parse_products(env["VELTRO_PRODUCTS"])
|
|
54
|
+
return parse_products(cfg.products) if cfg else PRODUCTS
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Veltro release versions, the tags they are published under, and the newest one.
|
|
2
|
+
|
|
3
|
+
One Veltro version, ``X.Y.Z``, names a release (ADR-016 §30). Releases up to
|
|
4
|
+
0.14.2 were tagged ``suite-vX.Y.Z``; every later one is ``vX.Y.Z``. The
|
|
5
|
+
numbering never overlaps, so the version alone names the tag. The release
|
|
6
|
+
workflow resolves tags through ``scripts/suite_manifest.py release-tag``, which
|
|
7
|
+
calls :func:`release_tag` here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
18
|
+
|
|
19
|
+
RELEASE_REPO = "veltrosecurity/veltro"
|
|
20
|
+
RELEASE_BASE = f"https://github.com/{RELEASE_REPO}"
|
|
21
|
+
# GitHub lists releases newest first, so the newest release is on page one.
|
|
22
|
+
RELEASES_API = f"https://api.github.com/repos/{RELEASE_REPO}/releases?per_page=100"
|
|
23
|
+
LAST_SUITE_TAGGED_RELEASE = (0, 14, 2)
|
|
24
|
+
|
|
25
|
+
_VERSION_RE = re.compile(r"^(?:suite-v|v)?(\d+)\.(\d+)\.(\d+)$")
|
|
26
|
+
_TAG_RE = re.compile(r"^(?:suite-)?v\d+\.\d+\.\d+$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_release_version(text: str) -> tuple[int, int, int]:
|
|
30
|
+
"""``X.Y.Z``, ``vX.Y.Z`` or ``suite-vX.Y.Z`` -> ``(X, Y, Z)``."""
|
|
31
|
+
match = _VERSION_RE.match(text.strip())
|
|
32
|
+
if not match:
|
|
33
|
+
raise VeltroCliError(
|
|
34
|
+
f"unparseable Veltro release version {text!r}",
|
|
35
|
+
remedy="name a release as X.Y.Z, e.g. 0.15.0",
|
|
36
|
+
)
|
|
37
|
+
major, minor, patch = (int(part) for part in match.groups())
|
|
38
|
+
return major, minor, patch
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def release_version(text: str) -> str:
|
|
42
|
+
"""The bare ``X.Y.Z`` a version or either tag spelling names."""
|
|
43
|
+
return "{}.{}.{}".format(*parse_release_version(text))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def release_tag(version: str) -> str:
|
|
47
|
+
"""The tag the release of ``version`` is published under."""
|
|
48
|
+
parsed = parse_release_version(version)
|
|
49
|
+
prefix = "suite-v" if parsed <= LAST_SUITE_TAGGED_RELEASE else "v"
|
|
50
|
+
return prefix + "{}.{}.{}".format(*parsed)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_release_tag(text: str) -> bool:
|
|
54
|
+
"""True for a release tag of either series, false for a ``source:`` path."""
|
|
55
|
+
return bool(_TAG_RE.match(text))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def latest_release_version(
|
|
59
|
+
*,
|
|
60
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
61
|
+
timeout: float = 15,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""The newest published Veltro release, ``X.Y.Z``.
|
|
64
|
+
|
|
65
|
+
Drafts, prereleases and tags outside the release series (the retired
|
|
66
|
+
``identity-v*`` lane) are skipped.
|
|
67
|
+
"""
|
|
68
|
+
remedy = "name the release with --suite-version X.Y.Z, or allow HTTPS to api.github.com"
|
|
69
|
+
request = urllib.request.Request(
|
|
70
|
+
RELEASES_API, headers={"Accept": "application/vnd.github+json"}
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
with opener(request, timeout=timeout) as response:
|
|
74
|
+
payload = json.load(response)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
raise VeltroCliError(f"could not list Veltro releases: {exc}", remedy=remedy) from exc
|
|
77
|
+
published = [
|
|
78
|
+
parse_release_version(release["tag_name"])
|
|
79
|
+
for release in (payload if isinstance(payload, list) else ())
|
|
80
|
+
if isinstance(release, dict)
|
|
81
|
+
and not release.get("draft")
|
|
82
|
+
and not release.get("prerelease")
|
|
83
|
+
and isinstance(release.get("tag_name"), str)
|
|
84
|
+
and is_release_tag(release["tag_name"])
|
|
85
|
+
]
|
|
86
|
+
if not published:
|
|
87
|
+
raise VeltroCliError("no published Veltro release found", remedy=remedy)
|
|
88
|
+
return "{}.{}.{}".format(*max(published))
|