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,379 @@
1
+ """Fetch and apply one immutable Veltro suite release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import urllib.request
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Mapping
16
+
17
+ from veltro_cli.lib.compose import ComposeClient
18
+ from veltro_cli.lib.errors import VeltroCliError
19
+ from veltro_cli.lib.install import materialise_from_checkout, materialise_from_tarball
20
+ from veltro_cli.lib.paths import resolve_dir
21
+ from veltro_cli.lib.products import read_env_file, resolve_selection
22
+ from veltro_cli.lib.release import (
23
+ RELEASE_BASE,
24
+ RELEASE_REPO,
25
+ parse_release_version,
26
+ release_tag,
27
+ release_version,
28
+ )
29
+ from veltro_cli.lib.toml_config import VELTRO_TOML, read_config, write_config
30
+
31
+ VERSION_VARS = {
32
+ "vectorflow": "VF_VERSION",
33
+ "chad": "CHAD_VERSION",
34
+ "warden": "WARDEN_VERSION",
35
+ "veltro": "VELTRO_VERSION",
36
+ }
37
+
38
+
39
+ def normalise_target(target: str) -> tuple[str, str]:
40
+ """``(tag, X.Y.Z)`` for a release named by its version or either tag spelling."""
41
+ version = release_version(target)
42
+ return release_tag(version), version
43
+
44
+
45
+ def refuse_downgrade(current: str, target: str) -> None:
46
+ cur = parse_release_version(current)
47
+ tgt = parse_release_version(target)
48
+ if tgt < cur:
49
+ raise VeltroCliError(
50
+ f"refusing downgrade: installed {current} → target {target}",
51
+ remedy="restore from backup if you need an older suite; upgrade never rolls backward",
52
+ )
53
+ if tgt == cur:
54
+ raise VeltroCliError(
55
+ f"already at {current}",
56
+ remedy="pass a newer --to X.Y.Z",
57
+ )
58
+
59
+
60
+ def fetch_release_notes(body: str) -> list[str]:
61
+ warnings: list[str] = []
62
+ for heading in ("BREAKING", "MIGRATION"):
63
+ if re.search(rf"^#+ .*{heading}", body, re.I | re.M):
64
+ warnings.append(heading)
65
+ return warnings
66
+
67
+
68
+ def release_urls(target: str) -> tuple[str, str]:
69
+ tag, version = normalise_target(target)
70
+ return (
71
+ f"{RELEASE_BASE}/releases/download/{tag}/veltro-suite-{version}.json",
72
+ f"{RELEASE_BASE}/archive/refs/tags/{tag}.tar.gz",
73
+ )
74
+
75
+
76
+ def load_release_manifest(
77
+ target: str,
78
+ *,
79
+ opener: Callable[..., Any] = urllib.request.urlopen,
80
+ ) -> dict[str, Any]:
81
+ manifest_url, _ = release_urls(target)
82
+ try:
83
+ with opener(manifest_url, timeout=30) as response:
84
+ raw = response.read()
85
+ except Exception as exc:
86
+ raise VeltroCliError(
87
+ f"could not fetch suite manifest for {target}: {exc}",
88
+ remedy=f"verify the release exists at {manifest_url} and retry",
89
+ ) from exc
90
+ try:
91
+ manifest = json.loads(raw)
92
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
93
+ raise VeltroCliError(
94
+ "published suite manifest is not valid JSON",
95
+ remedy="do not upgrade; report the immutable release asset",
96
+ ) from exc
97
+ _, version = normalise_target(target)
98
+ if not isinstance(manifest, dict) or manifest.get("schema") not in {
99
+ "veltro-suite-manifest/v4",
100
+ "veltro-suite-manifest/v5",
101
+ }:
102
+ raise VeltroCliError("published suite manifest has an unsupported schema")
103
+ if manifest.get("suite_version") != version:
104
+ raise VeltroCliError(
105
+ f"manifest suite_version {manifest.get('suite_version')!r} does not match {version!r}",
106
+ remedy="do not upgrade; the release asset is inconsistent with its tag",
107
+ )
108
+ verification = manifest.get("verification")
109
+ if not isinstance(verification, dict) or verification.get("status") not in {
110
+ "smoke-passed",
111
+ "config-rendered",
112
+ }:
113
+ raise VeltroCliError(
114
+ "suite manifest is not release-gate attested",
115
+ remedy="choose a published suite release with verification evidence",
116
+ )
117
+ return manifest
118
+
119
+
120
+ def manifest_versions(manifest: Mapping[str, Any]) -> dict[str, str]:
121
+ modules = manifest.get("modules")
122
+ if not isinstance(modules, dict):
123
+ raise VeltroCliError("suite manifest has no module versions")
124
+ versions: dict[str, str] = {}
125
+ for name, env_var in VERSION_VARS.items():
126
+ module = modules.get(name)
127
+ if not isinstance(module, dict) or not isinstance(module.get("version"), str):
128
+ raise VeltroCliError(f"suite manifest has no {name} version")
129
+ versions[env_var] = module["version"]
130
+ return versions
131
+
132
+
133
+ def verify_staged_pins(root: Path, manifest: Mapping[str, Any]) -> None:
134
+ actual = read_env_file(root / "versions.env")
135
+ expected = manifest_versions(manifest)
136
+ mismatches = [
137
+ f"{key}={actual.get(key)!r} (manifest {value!r})"
138
+ for key, value in expected.items()
139
+ if actual.get(key) != value
140
+ ]
141
+ if mismatches:
142
+ raise VeltroCliError(
143
+ "release tarball pins disagree with its manifest: " + ", ".join(mismatches),
144
+ remedy="do not upgrade; report the immutable release artifacts",
145
+ )
146
+
147
+
148
+ def verify_staged_release(root: Path, manifest: Mapping[str, Any]) -> None:
149
+ """Bind extracted release bytes to the release-gated manifest."""
150
+ verify_staged_pins(root, manifest)
151
+ contract = manifest.get("config_contract")
152
+ files = contract.get("files") if isinstance(contract, dict) else None
153
+ if not isinstance(files, dict) or not files:
154
+ raise VeltroCliError(
155
+ "suite manifest has no config artifact digests",
156
+ remedy="do not install or upgrade from an unattested release",
157
+ )
158
+ for relative, expected in files.items():
159
+ if not isinstance(relative, str) or not isinstance(expected, str):
160
+ raise VeltroCliError("suite manifest has a malformed config artifact digest")
161
+ path = root / relative
162
+ actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
163
+ if actual != expected:
164
+ raise VeltroCliError(
165
+ f"release artifact {relative} digest {actual!r} does not match {expected!r}",
166
+ remedy="do not install or upgrade; report the immutable release artifacts",
167
+ )
168
+
169
+ def _run_backup(root: Path, products: tuple[str, ...], staged: Path) -> Path:
170
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
171
+ archive = root / "backups" / f"veltro-backup-auto-{stamp}.tar"
172
+ archive.parent.mkdir(parents=True, exist_ok=True)
173
+ command = [
174
+ sys.executable,
175
+ str(staged / "scripts" / "veltro_backup.py"),
176
+ "backup",
177
+ "--dir",
178
+ str(root),
179
+ "--products",
180
+ ",".join(products),
181
+ "--to",
182
+ str(archive),
183
+ ]
184
+ # The old checkout still has legacy backup code and profile names. Run the
185
+ # staged backup against old pins/.env, but render the staged Compose services
186
+ # under the existing project name so exec finds the running containers.
187
+ env = dict(os.environ)
188
+ env["COMPOSE_FILE"] = str(staged / "docker-compose.yml")
189
+ env["COMPOSE_PROJECT_NAME"] = (
190
+ env.get("COMPOSE_PROJECT_NAME")
191
+ or read_env_file(root / ".env").get("COMPOSE_PROJECT_NAME")
192
+ or root.name
193
+ )
194
+ completed = subprocess.run(command, text=True, capture_output=True, check=False, env=env)
195
+ if completed.returncode != 0:
196
+ raise VeltroCliError(
197
+ completed.stderr.strip() or "automatic pre-upgrade backup failed",
198
+ remedy="fix backup prerequisites; upgrade has not changed the install",
199
+ )
200
+ if not archive.is_file():
201
+ raise VeltroCliError(
202
+ f"automatic backup reported success but did not create {archive}",
203
+ remedy="upgrade has not changed the install; inspect the backup command output",
204
+ )
205
+ return archive
206
+
207
+
208
+ def expected_image_digests(manifest: Mapping[str, Any]) -> dict[str, str]:
209
+ expected: dict[str, str] = {}
210
+ modules = manifest.get("modules")
211
+ if isinstance(modules, dict):
212
+ for module in modules.values():
213
+ if not isinstance(module, dict):
214
+ continue
215
+ images = module.get("images")
216
+ if isinstance(images, dict):
217
+ expected.update(
218
+ {
219
+ ref: digest
220
+ for ref, digest in images.items()
221
+ if isinstance(ref, str)
222
+ and isinstance(digest, str)
223
+ and digest.startswith("sha256:")
224
+ }
225
+ )
226
+ return expected
227
+
228
+
229
+ def verify_local_image_digests(
230
+ manifest: Mapping[str, Any],
231
+ *,
232
+ runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
233
+ ) -> None:
234
+ for ref, digest in expected_image_digests(manifest).items():
235
+ completed = runner(
236
+ ["docker", "image", "inspect", "--format", "{{json .RepoDigests}}", ref],
237
+ text=True,
238
+ capture_output=True,
239
+ check=False,
240
+ )
241
+ if completed.returncode != 0 or f"@{digest}" not in completed.stdout:
242
+ raise VeltroCliError(
243
+ f"running image {ref} is not the manifest digest {digest}",
244
+ remedy="run `veltro down`, restore the automatic backup, and inspect registry access",
245
+ )
246
+
247
+
248
+ def module_version_deltas(root: Path, manifest: Mapping[str, Any]) -> list[dict[str, str]]:
249
+ current = read_env_file(root / "versions.env")
250
+ target = manifest_versions(manifest)
251
+ return [
252
+ {"module": name, "from": current.get(env_var, "unknown"), "to": target[env_var]}
253
+ for name, env_var in VERSION_VARS.items()
254
+ if current.get(env_var) != target[env_var]
255
+ ]
256
+
257
+
258
+ def load_release_warnings(
259
+ target: str,
260
+ manifest: Mapping[str, Any],
261
+ *,
262
+ opener: Callable[..., Any] = urllib.request.urlopen,
263
+ ) -> list[str]:
264
+ tag, _ = normalise_target(target)
265
+ releases: list[tuple[str, str, str]] = [("suite", RELEASE_REPO, tag)]
266
+ modules = manifest.get("modules")
267
+ if isinstance(modules, dict):
268
+ for name, module in modules.items():
269
+ if isinstance(module, dict) and isinstance(module.get("repo"), str) and isinstance(module.get("tag"), str):
270
+ releases.append((name, module["repo"], module["tag"]))
271
+ warnings: list[str] = []
272
+ for name, repo, notes_tag in releases:
273
+ request = urllib.request.Request(
274
+ f"https://api.github.com/repos/{repo}/releases/tags/{notes_tag}",
275
+ headers={"Accept": "application/vnd.github+json"},
276
+ )
277
+ try:
278
+ with opener(request, timeout=15) as response:
279
+ payload = json.load(response)
280
+ except Exception as exc:
281
+ warnings.append(f"{name} release notes unavailable: {exc}")
282
+ continue
283
+ body = payload.get("body") if isinstance(payload, dict) else ""
284
+ for heading in fetch_release_notes(body if isinstance(body, str) else ""):
285
+ warnings.append(f"{name}: {heading} section present in release notes")
286
+ return warnings
287
+
288
+ def plan_upgrade(
289
+ *,
290
+ directory: str | Path | None,
291
+ target: str,
292
+ current: str | None = None,
293
+ manifest_opener: Callable[..., Any] = urllib.request.urlopen,
294
+ notes_opener: Callable[..., Any] = urllib.request.urlopen,
295
+ ) -> dict[str, Any]:
296
+ root = resolve_dir(directory)
297
+ cfg = read_config(root / VELTRO_TOML)
298
+ installed = current or (cfg.installed_suite_version if cfg else "") or "0.0.0"
299
+ refuse_downgrade(installed, target)
300
+ manifest = load_release_manifest(target, opener=manifest_opener)
301
+ return {
302
+ "directory": str(root),
303
+ "from": installed,
304
+ "to": normalise_target(target)[0],
305
+ "dry_run": True,
306
+ "module_deltas": module_version_deltas(root, manifest),
307
+ "warnings": load_release_warnings(target, manifest, opener=notes_opener),
308
+ "steps": [
309
+ "fetch and verify immutable suite manifest",
310
+ "veltro backup --auto",
311
+ "replace compose/nginx/versions.env from release",
312
+ "veltro up --wait",
313
+ "verify local image digests against suite manifest",
314
+ "record installed_version in veltro.toml",
315
+ ],
316
+ }
317
+
318
+
319
+ def execute_upgrade(
320
+ *,
321
+ directory: str | Path | None,
322
+ target: str,
323
+ current: str | None = None,
324
+ manifest_opener: Callable[..., Any] = urllib.request.urlopen,
325
+ tarball_opener: Callable[..., Any] = urllib.request.urlopen,
326
+ backup_runner: Callable[[Path, tuple[str, ...], Path], Path] = _run_backup,
327
+ client_factory: Callable[..., ComposeClient] = ComposeClient,
328
+ digest_verifier: Callable[[Mapping[str, Any]], None] = verify_local_image_digests,
329
+ ) -> dict[str, Any]:
330
+ root = resolve_dir(directory)
331
+ cfg = read_config(root / VELTRO_TOML)
332
+ if cfg is None:
333
+ raise VeltroCliError("veltro.toml is missing", remedy="run `veltro install` first")
334
+ installed = current or cfg.installed_suite_version or "0.0.0"
335
+ refuse_downgrade(installed, target)
336
+ manifest = load_release_manifest(target, opener=manifest_opener)
337
+ _, tarball_url = release_urls(target)
338
+ products = resolve_selection(root)
339
+ with tempfile.TemporaryDirectory(prefix="veltro-upgrade-") as tmp:
340
+ staged = Path(tmp) / "release"
341
+ materialise_from_tarball(tarball_url, staged, opener=tarball_opener)
342
+ verify_staged_release(staged, manifest)
343
+ backup_path = backup_runner(root, products, staged)
344
+ materialise_from_checkout(staged, root)
345
+ if cfg.tls_mode != "off":
346
+ from veltro_cli.lib.tls import write_caddyfile
347
+
348
+ write_caddyfile(
349
+ root,
350
+ tls_mode=cfg.tls_mode,
351
+ public_url=cfg.public_url,
352
+ products=products,
353
+ )
354
+ client = client_factory(
355
+ root,
356
+ products=products,
357
+ tls_mode=cfg.tls_mode,
358
+ monitoring=cfg.monitoring,
359
+ )
360
+ result = client.up()
361
+ if result.returncode != 0:
362
+ raise VeltroCliError(
363
+ result.stderr.strip() or "upgraded services did not become healthy",
364
+ remedy=f"run `veltro restore {backup_path} --yes`",
365
+ )
366
+ digest_verifier(manifest)
367
+ tag, _ = normalise_target(target)
368
+ write_config(
369
+ root / VELTRO_TOML,
370
+ type(cfg)(
371
+ products=",".join(products),
372
+ public_url=cfg.public_url,
373
+ tls_mode=cfg.tls_mode,
374
+ dir=cfg.dir,
375
+ installed_suite_version=tag,
376
+ monitoring=cfg.monitoring,
377
+ ),
378
+ )
379
+ return {"from": installed, "to": tag, "backup": str(backup_path)}
veltro_cli/py.typed ADDED
File without changes
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: veltro-cli
3
+ Version: 0.15.7
4
+ Summary: Operator CLI for the Veltro security operations suite (Compose + Helm).
5
+ Author: Veltro Security
6
+ License: AGPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/veltrosecurity/veltro
8
+ Project-URL: Documentation, https://github.com/veltrosecurity/veltro#readme
9
+ Project-URL: Repository, https://github.com/veltrosecurity/veltro
10
+ Project-URL: Issues, https://github.com/veltrosecurity/veltro/issues
11
+ Keywords: veltro,security,soc,compose,helm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+
27
+ # veltro-cli
28
+
29
+ Stdlib-only operator CLI for the Veltro suite. Every Veltro release publishes
30
+ it at the release's version. Install a release with:
31
+
32
+ ```sh
33
+ curl -fsSL https://raw.githubusercontent.com/veltrosecurity/veltro/vX.Y.Z/install.sh \
34
+ | VELTRO_RELEASE=X.Y.Z sh
35
+ ```
36
+
37
+ or `pip install veltro-cli==X.Y.Z` and `veltro install --suite-version X.Y.Z`.
38
+ Leave the version out to take the newest release.
39
+
40
+ Requires Python ≥ 3.10 and Docker Compose ≥ 2.20 on the host.
@@ -0,0 +1,31 @@
1
+ veltro_cli/__init__.py,sha256=VnqEClGNNd-QoD4KB-KKrdl4MiGH7UE79FDD29io9aY,162
2
+ veltro_cli/__main__.py,sha256=3TsZ7P9l58oD0WSz5Bs123zv4awf_A7F_zFLhjYktZ4,122
3
+ veltro_cli/cli.py,sha256=h4ktX-TGiUpnDfGIEJV1yizmtqw1IKm_jfh1c2Vq8Xk,18999
4
+ veltro_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ veltro_cli/lib/__init__.py,sha256=AfQgBH4JgPETDMOLmeY2mueXU8EPL-M9O76lpf0d0lE,98
6
+ veltro_cli/lib/agent.py,sha256=7U8c5x8e50jgU_BBlCpcuUNn_9RcZKzDUC6peRK65y4,1358
7
+ veltro_cli/lib/backup.py,sha256=Xu6XjaFxiwHKald_WyUvY5ZLA72bAegCqfkFGdtES7U,4789
8
+ veltro_cli/lib/compose.py,sha256=-m9xsqRF7Dn4dryk-dkBwtaLvpVpZ4dndyAyo0mHC78,9316
9
+ veltro_cli/lib/datasets.py,sha256=BLGZYXnQ7HBbjl4KKO6b6SyWyBa9r6IMcsvmFLbB-Kc,23622
10
+ veltro_cli/lib/demo.py,sha256=gipzWJc9R-Na4h75rL6QIZpxQeAQn-6_wUsYaaPCf4g,16076
11
+ veltro_cli/lib/demo_replay.py,sha256=i6PLEKFPCobcE_-PEYKSVcqr5ipUdnD_q8Mab1Drd20,32353
12
+ veltro_cli/lib/doctor.py,sha256=KvOJlvGkg2MN4gy7nf1ODuMuEMZNuetB7LZI2sJC4bM,3097
13
+ veltro_cli/lib/env_init.py,sha256=27J8NfiLLasSRGQ79dtskRb3Dum6xjvI21YqdwcwJqg,3416
14
+ veltro_cli/lib/errors.py,sha256=uUEtGpdMLSM5_A8S_u-Gg2zGZ1uaZVluMHUNzJZrvsw,1083
15
+ veltro_cli/lib/host.py,sha256=GQdnNppsYqaI26yFDIE0E_2mpeyjGbBTdeZa3QlrgwY,11267
16
+ veltro_cli/lib/install.py,sha256=2CghrBRB7t8SlBm4P9YC5UbcIATTanwbvu0TyjEklJs,15287
17
+ veltro_cli/lib/k8s.py,sha256=WzpsrMjqgUIRzI19Ywy2xs4FcH45PPq30CLdCT8IamI,2026
18
+ veltro_cli/lib/paths.py,sha256=n2SzPs-0-vJant86WIpnHbg44j3Dhtt0YKBoTbWvqXM,1489
19
+ veltro_cli/lib/products.py,sha256=XtyJHSA_tXLLMtyTO72qmeOsX_8yoM_Qris6-gcUrqE,2037
20
+ veltro_cli/lib/release.py,sha256=uj4ltZfSFkI0A8VtWrKFBCEE-DaWYX4uJXS0Rcb0wsQ,3303
21
+ veltro_cli/lib/status.py,sha256=W4Z7ONM46XXdswVNOuTqY8b56fnAvexyzMxI8_eg-68,6434
22
+ veltro_cli/lib/suite_http.py,sha256=x8nVimYw9OcG1MfnYEFOrPGWkdvdgbW41kTeDFFuRsA,9354
23
+ veltro_cli/lib/support.py,sha256=rqLZAiAaps-JiJx4CWttMnzlnKEnA9aAHRbDHGL9zdA,1169
24
+ veltro_cli/lib/tls.py,sha256=-h4M3SUH_7_ZL8xnvXYCZQ5QXgC7PXhtSqi2u4BUjzU,3675
25
+ veltro_cli/lib/toml_config.py,sha256=pWqlJnaOEno5fVivVTtkbsvLW13IYy9PjvCSvKc1LXc,3822
26
+ veltro_cli/lib/upgrade.py,sha256=LtPumvD6DrxQid6TfHKUpmW12LjGBLweCEcQKHXlgek,14519
27
+ veltro_cli-0.15.7.dist-info/METADATA,sha256=fsjKbb8oGyNgsgVdwP0XI2Z1eD2ylCDdvHVlCiLC--w,1622
28
+ veltro_cli-0.15.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
29
+ veltro_cli-0.15.7.dist-info/entry_points.txt,sha256=YAiC7EzAE1y2ekqMrqzKXjTzfC61Dre003Lq_UFKH5Y,47
30
+ veltro_cli-0.15.7.dist-info/top_level.txt,sha256=WhGjLaHoeUKxcuWg-8GxmhXJc61uPYz3l4OCWU_nGBE,11
31
+ veltro_cli-0.15.7.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ veltro = veltro_cli.cli:main
@@ -0,0 +1 @@
1
+ veltro_cli