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