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
veltro_cli/lib/status.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""veltro status — compose ps + HTTP probes + suite version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Mapping
|
|
11
|
+
|
|
12
|
+
from veltro_cli.lib.compose import ComposeClient
|
|
13
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
14
|
+
from veltro_cli.lib.products import read_env_file
|
|
15
|
+
from veltro_cli.lib.release import latest_release_version, release_tag
|
|
16
|
+
from veltro_cli.lib.toml_config import VELTRO_TOML, read_config
|
|
17
|
+
|
|
18
|
+
Fetcher = Callable[[str, float], tuple[int, str]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Probe:
|
|
23
|
+
name: str
|
|
24
|
+
path: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PROBES = (Probe("suite-status", "/api/suite/status"),)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_fetcher(
|
|
31
|
+
url: str,
|
|
32
|
+
timeout: float = 3.0,
|
|
33
|
+
*,
|
|
34
|
+
headers: Mapping[str, str] | None = None,
|
|
35
|
+
) -> tuple[int, str]:
|
|
36
|
+
request_headers = {"Accept": "application/json", **dict(headers or {})}
|
|
37
|
+
req = urllib.request.Request(url, headers=request_headers, method="GET")
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
40
|
+
body = resp.read().decode("utf-8", errors="replace")
|
|
41
|
+
return int(resp.status), body
|
|
42
|
+
except urllib.error.HTTPError as exc:
|
|
43
|
+
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
|
|
44
|
+
return int(exc.code), body
|
|
45
|
+
except Exception as exc: # noqa: BLE001 — surface any transport failure
|
|
46
|
+
return 0, str(exc)
|
|
47
|
+
|
|
48
|
+
def fetch_latest_suite_version(
|
|
49
|
+
*, opener: Callable[..., Any] = urllib.request.urlopen
|
|
50
|
+
) -> str | None:
|
|
51
|
+
"""The newest release's tag, or None when the lookup is unavailable."""
|
|
52
|
+
try:
|
|
53
|
+
return release_tag(latest_release_version(opener=opener, timeout=3))
|
|
54
|
+
except VeltroCliError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def collect_status(
|
|
60
|
+
root: Path,
|
|
61
|
+
*,
|
|
62
|
+
products: tuple[str, ...] = ("vectorflow", "chad", "warden"),
|
|
63
|
+
public_url: str = "http://localhost",
|
|
64
|
+
client: ComposeClient | None = None,
|
|
65
|
+
fetcher: Fetcher = default_fetcher,
|
|
66
|
+
tls_mode: str = "off",
|
|
67
|
+
check_latest: bool = True,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
client = client or ComposeClient(root, products=products, tls_mode=tls_mode)
|
|
70
|
+
ps = client.ps("--format", "json")
|
|
71
|
+
services: list[dict[str, Any]] = []
|
|
72
|
+
if ps.returncode == 0 and ps.stdout.strip():
|
|
73
|
+
# compose ps --format json emits either a JSON array or NDJSON depending on version.
|
|
74
|
+
text = ps.stdout.strip()
|
|
75
|
+
try:
|
|
76
|
+
parsed = json.loads(text)
|
|
77
|
+
if isinstance(parsed, list):
|
|
78
|
+
services = parsed
|
|
79
|
+
elif isinstance(parsed, dict):
|
|
80
|
+
services = [parsed]
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
for line in text.splitlines():
|
|
83
|
+
try:
|
|
84
|
+
services.append(json.loads(line))
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
base = public_url.rstrip("/")
|
|
89
|
+
probes = PROBES
|
|
90
|
+
|
|
91
|
+
probe_results = []
|
|
92
|
+
for probe in probes:
|
|
93
|
+
url = base.rstrip("/") + probe.path
|
|
94
|
+
code, body = fetcher(url, 3.0)
|
|
95
|
+
state = "ready" if 200 <= code < 300 else ("down" if code == 0 else "degraded")
|
|
96
|
+
probe_results.append(
|
|
97
|
+
{
|
|
98
|
+
"name": probe.name,
|
|
99
|
+
"url": url,
|
|
100
|
+
"http_status": code,
|
|
101
|
+
"state": state,
|
|
102
|
+
"body_preview": body[:200],
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
if probe.name == "suite-status" and 200 <= code < 300:
|
|
106
|
+
try:
|
|
107
|
+
suite_status = json.loads(body)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
suite_status = None
|
|
110
|
+
if isinstance(suite_status, dict) and suite_status.get("schema") == "veltro-suite-status/v2":
|
|
111
|
+
components = suite_status.get("components")
|
|
112
|
+
if isinstance(components, list):
|
|
113
|
+
for component in components:
|
|
114
|
+
if not isinstance(component, dict):
|
|
115
|
+
continue
|
|
116
|
+
name = component.get("id")
|
|
117
|
+
component_state = component.get("status")
|
|
118
|
+
if not isinstance(name, str) or component_state not in {"ready", "degraded", "down", "unknown", "not_installed"}:
|
|
119
|
+
continue
|
|
120
|
+
probe_results.append(
|
|
121
|
+
{
|
|
122
|
+
"name": name,
|
|
123
|
+
"url": url,
|
|
124
|
+
"http_status": code,
|
|
125
|
+
"state": component_state,
|
|
126
|
+
"body_preview": json.dumps(component, separators=(",", ":"))[:200],
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
pins = read_env_file(root / "versions.env")
|
|
131
|
+
cfg = read_config(root / VELTRO_TOML)
|
|
132
|
+
return {
|
|
133
|
+
"products": list(products),
|
|
134
|
+
"public_url": public_url,
|
|
135
|
+
"installed_suite_version": cfg.installed_suite_version if cfg else None,
|
|
136
|
+
"latest_suite_version": fetch_latest_suite_version() if check_latest else None,
|
|
137
|
+
"suite_pins": pins,
|
|
138
|
+
"services": services,
|
|
139
|
+
"probes": probe_results,
|
|
140
|
+
"compose_ps_returncode": ps.returncode,
|
|
141
|
+
"compose_ps_stderr": ps.stderr.strip(),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def format_status(doc: dict[str, Any]) -> str:
|
|
146
|
+
installed = doc.get("installed_suite_version") or "unknown"
|
|
147
|
+
latest = doc.get("latest_suite_version") or "offline"
|
|
148
|
+
lines = [
|
|
149
|
+
f"products: {','.join(doc['products']) or '(core only)'}",
|
|
150
|
+
f"public_url: {doc['public_url']}",
|
|
151
|
+
f"suite_version: {installed} (latest: {latest})",
|
|
152
|
+
"pins:",
|
|
153
|
+
]
|
|
154
|
+
for key in ("VF_VERSION", "CHAD_VERSION", "WARDEN_VERSION", "VELTRO_VERSION"):
|
|
155
|
+
if key in doc.get("suite_pins", {}):
|
|
156
|
+
lines.append(f" {key}={doc['suite_pins'][key]}")
|
|
157
|
+
lines.append("services:")
|
|
158
|
+
services = doc.get("services") or []
|
|
159
|
+
if not services:
|
|
160
|
+
lines.append(" (none — is the stack up?)")
|
|
161
|
+
for svc in services:
|
|
162
|
+
name = svc.get("Service") or svc.get("Name") or svc.get("Name") or "?"
|
|
163
|
+
state = svc.get("State") or svc.get("Status") or "?"
|
|
164
|
+
health = svc.get("Health") or ""
|
|
165
|
+
extra = f" health={health}" if health else ""
|
|
166
|
+
lines.append(f" {name}: {state}{extra}")
|
|
167
|
+
lines.append("probes:")
|
|
168
|
+
for probe in doc.get("probes") or []:
|
|
169
|
+
lines.append(f" {probe['name']}: {probe['state']} (HTTP {probe['http_status']}) {probe['url']}")
|
|
170
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Minimal suite HTTP client: cookie session, CSRF, JSON — stdlib only.
|
|
2
|
+
|
|
3
|
+
The transport is injectable so every caller can be tested without a network or
|
|
4
|
+
a running stack: pass ``transport=`` a callable
|
|
5
|
+
``(method, url, headers, body) -> (status, headers, text)``.
|
|
6
|
+
|
|
7
|
+
Cookie and CSRF handling mirrors scripts/seed-demo.sh exactly, because they are
|
|
8
|
+
talking to the same three planes:
|
|
9
|
+
|
|
10
|
+
* identity (`/api/auth/*`, `/api/trpc/*`) validates `x-csrf-token` against the
|
|
11
|
+
token `GET /api/auth/csrf` issued for this jar;
|
|
12
|
+
* CHAD and Warden run a double-submit check against the `csrf_token` cookie
|
|
13
|
+
they set on the first safe request;
|
|
14
|
+
* the ingest plane (`/api/logs/<suffix>`) is bearer-authenticated and takes no
|
|
15
|
+
cookie at all.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from http.cookies import SimpleCookie
|
|
26
|
+
from typing import Any, Callable, Mapping
|
|
27
|
+
|
|
28
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
29
|
+
|
|
30
|
+
Transport = Callable[[str, str, Mapping[str, str], bytes | None], "tuple[int, Mapping[str, str], str]"]
|
|
31
|
+
|
|
32
|
+
SESSION_COOKIE = "authjs.session-token"
|
|
33
|
+
PRODUCT_CSRF_COOKIE = "csrf_token"
|
|
34
|
+
DEFAULT_TIMEOUT = 30
|
|
35
|
+
# Every answer this client reads is a JSON control-plane document: the largest
|
|
36
|
+
# real one is a 500-row CHAD alert page. Reading an unbounded body into memory
|
|
37
|
+
# because a product (or something in front of it) streamed one is not a
|
|
38
|
+
# failure mode a demo seeder needs, so the read is capped and named rather
|
|
39
|
+
# than silently truncated, which would surface as a JSON parse error instead.
|
|
40
|
+
MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Response:
|
|
45
|
+
status: int
|
|
46
|
+
headers: Mapping[str, str]
|
|
47
|
+
text: str
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def ok(self) -> bool:
|
|
51
|
+
return 200 <= self.status < 300
|
|
52
|
+
|
|
53
|
+
def json(self, default: Any = None) -> Any:
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(self.text)
|
|
56
|
+
except (json.JSONDecodeError, TypeError):
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
def detail(self, limit: int = 300) -> str:
|
|
60
|
+
"""FastAPI `detail`, CHAD `message`, tRPC error, else a truncated body."""
|
|
61
|
+
body = self.json()
|
|
62
|
+
if isinstance(body, Mapping):
|
|
63
|
+
for key in ("detail", "message"):
|
|
64
|
+
value = body.get(key)
|
|
65
|
+
if isinstance(value, str) and value.strip():
|
|
66
|
+
return value.strip()
|
|
67
|
+
error = body.get("error")
|
|
68
|
+
if isinstance(error, Mapping):
|
|
69
|
+
inner = error.get("json")
|
|
70
|
+
if isinstance(inner, Mapping) and isinstance(inner.get("message"), str):
|
|
71
|
+
return str(inner["message"])
|
|
72
|
+
unmapped = body.get("unmapped_fields")
|
|
73
|
+
if unmapped:
|
|
74
|
+
return f"unmapped_fields={unmapped}"
|
|
75
|
+
return (self.text or "")[:limit]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_capped(reader: Any, method: str, url: str) -> str:
|
|
79
|
+
"""Read at most `MAX_RESPONSE_BYTES`, refusing anything longer by name."""
|
|
80
|
+
payload = reader.read(MAX_RESPONSE_BYTES + 1)
|
|
81
|
+
if len(payload) > MAX_RESPONSE_BYTES:
|
|
82
|
+
raise VeltroCliError(
|
|
83
|
+
f"{method} {url} answered with more than {MAX_RESPONSE_BYTES} bytes",
|
|
84
|
+
remedy="narrow the request (page it with `limit=`), or check what is answering that path",
|
|
85
|
+
)
|
|
86
|
+
return payload.decode("utf-8", "replace")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _urllib_transport(
|
|
90
|
+
method: str, url: str, headers: Mapping[str, str], body: bytes | None
|
|
91
|
+
) -> tuple[int, Mapping[str, str], str]:
|
|
92
|
+
request = urllib.request.Request(url, data=body, method=method)
|
|
93
|
+
for name, value in headers.items():
|
|
94
|
+
request.add_header(name, value)
|
|
95
|
+
try:
|
|
96
|
+
with urllib.request.urlopen(request, timeout=DEFAULT_TIMEOUT) as response:
|
|
97
|
+
return response.status, dict(response.headers.items()), _read_capped(response, method, url)
|
|
98
|
+
except urllib.error.HTTPError as error: # a status is an answer, not a failure
|
|
99
|
+
payload = _read_capped(error, method, url) if error.fp else ""
|
|
100
|
+
return error.code, dict(error.headers.items() if error.headers else {}), payload
|
|
101
|
+
except urllib.error.URLError as error:
|
|
102
|
+
raise VeltroCliError(
|
|
103
|
+
f"{method} {url} failed: {error.reason}",
|
|
104
|
+
remedy="check the suite is running and PUBLIC_BASE_URL is reachable (`veltro status`)",
|
|
105
|
+
) from error
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class SuiteSession:
|
|
110
|
+
"""One authenticated browser-equivalent session against the suite origin."""
|
|
111
|
+
|
|
112
|
+
base_url: str
|
|
113
|
+
transport: Transport = _urllib_transport
|
|
114
|
+
cookies: dict[str, str] = field(default_factory=dict)
|
|
115
|
+
identity_csrf: str | None = None
|
|
116
|
+
|
|
117
|
+
def url(self, path: str) -> str:
|
|
118
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
119
|
+
return path
|
|
120
|
+
return f"{self.base_url.rstrip('/')}{path if path.startswith('/') else '/' + path}"
|
|
121
|
+
|
|
122
|
+
def _absorb_cookies(self, headers: Mapping[str, str]) -> None:
|
|
123
|
+
raw = headers.get("Set-Cookie") or headers.get("set-cookie")
|
|
124
|
+
if not raw:
|
|
125
|
+
return
|
|
126
|
+
jar = SimpleCookie()
|
|
127
|
+
# Multiple Set-Cookie headers arrive joined by ", " through
|
|
128
|
+
# http.client's header folding; SimpleCookie parses that shape.
|
|
129
|
+
jar.load(raw)
|
|
130
|
+
for name, morsel in jar.items():
|
|
131
|
+
# A cookie cleared by the server (empty value) must not linger:
|
|
132
|
+
# a stale session cookie would make a 401 look like a live session.
|
|
133
|
+
if morsel.value == "":
|
|
134
|
+
self.cookies.pop(name, None)
|
|
135
|
+
else:
|
|
136
|
+
self.cookies[name] = morsel.value
|
|
137
|
+
|
|
138
|
+
def request(
|
|
139
|
+
self,
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
*,
|
|
143
|
+
json_body: Any = None,
|
|
144
|
+
raw_body: bytes | None = None,
|
|
145
|
+
headers: Mapping[str, str] | None = None,
|
|
146
|
+
bearer: str | None = None,
|
|
147
|
+
send_cookies: bool = True,
|
|
148
|
+
) -> Response:
|
|
149
|
+
outgoing: dict[str, str] = {"accept": "application/json"}
|
|
150
|
+
body = raw_body
|
|
151
|
+
if json_body is not None:
|
|
152
|
+
body = json.dumps(json_body).encode("utf-8")
|
|
153
|
+
outgoing["content-type"] = "application/json"
|
|
154
|
+
if send_cookies and self.cookies:
|
|
155
|
+
outgoing["cookie"] = "; ".join(f"{name}={value}" for name, value in self.cookies.items())
|
|
156
|
+
if method.upper() not in {"GET", "HEAD"}:
|
|
157
|
+
product_csrf = self.cookies.get(PRODUCT_CSRF_COOKIE)
|
|
158
|
+
if product_csrf:
|
|
159
|
+
outgoing["X-CSRF-Token"] = product_csrf
|
|
160
|
+
if bearer:
|
|
161
|
+
outgoing["authorization"] = f"Bearer {bearer}"
|
|
162
|
+
for name, value in (headers or {}).items():
|
|
163
|
+
outgoing[name] = value
|
|
164
|
+
status, response_headers, text = self.transport(method.upper(), self.url(path), outgoing, body)
|
|
165
|
+
self._absorb_cookies(response_headers)
|
|
166
|
+
return Response(status=status, headers=response_headers, text=text)
|
|
167
|
+
|
|
168
|
+
def get(self, path: str, **kwargs: Any) -> Response:
|
|
169
|
+
return self.request("GET", path, **kwargs)
|
|
170
|
+
|
|
171
|
+
def post(self, path: str, **kwargs: Any) -> Response:
|
|
172
|
+
return self.request("POST", path, **kwargs)
|
|
173
|
+
|
|
174
|
+
# --- authentication -----------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def login(self, email: str, password: str) -> None:
|
|
177
|
+
"""Mint the suite session cookie the whole seed runs under."""
|
|
178
|
+
csrf = self.get("/api/auth/csrf")
|
|
179
|
+
token = (csrf.json({}) or {}).get("csrfToken") if csrf.ok else None
|
|
180
|
+
if not token:
|
|
181
|
+
raise VeltroCliError(
|
|
182
|
+
f"GET /api/auth/csrf did not issue a token (HTTP {csrf.status})",
|
|
183
|
+
remedy="check the identity application is ready (`veltro status`)",
|
|
184
|
+
)
|
|
185
|
+
self.identity_csrf = str(token)
|
|
186
|
+
response = self.post(
|
|
187
|
+
"/api/auth/login",
|
|
188
|
+
json_body={"email": email, "password": password},
|
|
189
|
+
headers={"x-csrf-token": self.identity_csrf},
|
|
190
|
+
)
|
|
191
|
+
if SESSION_COOKIE not in self.cookies:
|
|
192
|
+
raise VeltroCliError(
|
|
193
|
+
f"suite login for {email} minted no session cookie (HTTP {response.status}): {response.detail()}",
|
|
194
|
+
remedy="check VF_ADMIN_EMAIL / VF_ADMIN_PASSWORD name a suite admin",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# --- identity tRPC ------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def trpc_query(self, procedure: str, payload: Any = None) -> Response:
|
|
200
|
+
suffix = "" if payload is None else f"?input={urllib.parse.quote(json.dumps(payload))}"
|
|
201
|
+
return self.get(f"/api/trpc/{procedure}{suffix}")
|
|
202
|
+
|
|
203
|
+
def trpc_mutation(self, procedure: str, payload: Any) -> Response:
|
|
204
|
+
if not self.identity_csrf:
|
|
205
|
+
csrf = self.get("/api/auth/csrf")
|
|
206
|
+
token = (csrf.json({}) or {}).get("csrfToken") if csrf.ok else None
|
|
207
|
+
if not token:
|
|
208
|
+
raise VeltroCliError(
|
|
209
|
+
"no identity CSRF token available for a tRPC mutation",
|
|
210
|
+
remedy="log in first (`SuiteSession.login`)",
|
|
211
|
+
)
|
|
212
|
+
self.identity_csrf = str(token)
|
|
213
|
+
return self.post(
|
|
214
|
+
f"/api/trpc/{procedure}",
|
|
215
|
+
json_body=payload,
|
|
216
|
+
headers={"x-csrf-token": self.identity_csrf},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def trpc_data(response: Response) -> Any:
|
|
221
|
+
"""The `result.data` payload of an identity tRPC response, or None."""
|
|
222
|
+
body = response.json()
|
|
223
|
+
if isinstance(body, Mapping):
|
|
224
|
+
result = body.get("result")
|
|
225
|
+
if isinstance(result, Mapping):
|
|
226
|
+
return result.get("data")
|
|
227
|
+
return None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""veltro support-bundle — thin wrapper over scripts/support_bundle.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
9
|
+
from veltro_cli.lib.paths import resolve_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def capture_support_bundle(
|
|
13
|
+
*,
|
|
14
|
+
directory: str | Path | None = None,
|
|
15
|
+
output: str | Path | None = None,
|
|
16
|
+
base_url: str | None = None,
|
|
17
|
+
) -> Path:
|
|
18
|
+
root = resolve_dir(directory)
|
|
19
|
+
scripts = root / "scripts"
|
|
20
|
+
sys.path.insert(0, str(scripts))
|
|
21
|
+
try:
|
|
22
|
+
import support_bundle as sb # type: ignore
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise VeltroCliError(
|
|
25
|
+
"support_bundle.py not importable from scripts/",
|
|
26
|
+
remedy="run from a full Veltro checkout/install",
|
|
27
|
+
) from exc
|
|
28
|
+
out = Path(output) if output else root / "support-bundle.json"
|
|
29
|
+
argv = ["support_bundle.py", "--versions", str(root / "versions.env"), "--output", str(out)]
|
|
30
|
+
if base_url:
|
|
31
|
+
argv.extend(["--base-url", base_url])
|
|
32
|
+
code = sb.main(argv[1:])
|
|
33
|
+
if code != 0:
|
|
34
|
+
raise VeltroCliError("support bundle capture failed", remedy="check product diagnostics endpoints")
|
|
35
|
+
return out
|
veltro_cli/lib/tls.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""TLS mode helpers — Caddyfile rendering and compose env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
9
|
+
from veltro_cli.lib.toml_config import TLS_MODES
|
|
10
|
+
|
|
11
|
+
UPSTREAM = "veltro-nginx:80"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def host_from_public_url(public_url: str) -> str:
|
|
15
|
+
parsed = urlparse(public_url if "://" in public_url else f"https://{public_url}")
|
|
16
|
+
host = parsed.hostname
|
|
17
|
+
if not host:
|
|
18
|
+
raise VeltroCliError(
|
|
19
|
+
f"unparseable public url {public_url!r}",
|
|
20
|
+
remedy="pass --public-url https://soc.example.com",
|
|
21
|
+
)
|
|
22
|
+
return host
|
|
23
|
+
|
|
24
|
+
def validate_tls_public_url(public_url: str) -> None:
|
|
25
|
+
parsed = urlparse(public_url)
|
|
26
|
+
if parsed.scheme.lower() != "https" or not parsed.hostname:
|
|
27
|
+
raise VeltroCliError(
|
|
28
|
+
f"TLS requires an https public URL, got {public_url!r}",
|
|
29
|
+
remedy="pass --public-url https://soc.example.com, or use --tls off behind an external proxy",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_caddyfile(
|
|
34
|
+
*,
|
|
35
|
+
tls_mode: str,
|
|
36
|
+
public_url: str,
|
|
37
|
+
products: tuple[str, ...] = ("vectorflow", "chad", "warden"),
|
|
38
|
+
ingest_upstream: str = "chad-backend:8001",
|
|
39
|
+
) -> str:
|
|
40
|
+
if tls_mode not in TLS_MODES or tls_mode == "off":
|
|
41
|
+
raise VeltroCliError(
|
|
42
|
+
f"tls mode {tls_mode!r} does not need a Caddyfile",
|
|
43
|
+
remedy="use auto|internal|custom",
|
|
44
|
+
)
|
|
45
|
+
validate_tls_public_url(public_url)
|
|
46
|
+
host = host_from_public_url(public_url)
|
|
47
|
+
# Bind the public origin to the single gateway for every selection.
|
|
48
|
+
site = public_url if "://" in public_url else f"https://{host}"
|
|
49
|
+
|
|
50
|
+
if tls_mode == "auto":
|
|
51
|
+
tls_directive = "\t# ACME (HTTP-01 / TLS-ALPN) — requires public DNS\n"
|
|
52
|
+
elif tls_mode == "internal":
|
|
53
|
+
tls_directive = "\ttls internal\n"
|
|
54
|
+
else: # custom
|
|
55
|
+
tls_directive = (
|
|
56
|
+
"\ttls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem\n"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ingest_site = ""
|
|
60
|
+
if "chad" in products:
|
|
61
|
+
ingest_tls = (
|
|
62
|
+
"\ttls internal\n"
|
|
63
|
+
if tls_mode != "custom"
|
|
64
|
+
else "\ttls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem\n"
|
|
65
|
+
)
|
|
66
|
+
ingest_site = (
|
|
67
|
+
"\n# Direct CHAD ingest over TLS (raw :8001 stays private).\n"
|
|
68
|
+
":8443 {\n"
|
|
69
|
+
f"{ingest_tls}"
|
|
70
|
+
f"\treverse_proxy {ingest_upstream}\n"
|
|
71
|
+
"}\n"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
"# Generated by veltro-cli — do not hand-edit; re-run `veltro install --tls …`.\n"
|
|
76
|
+
"{\n"
|
|
77
|
+
"\tadmin off\n"
|
|
78
|
+
"}\n"
|
|
79
|
+
"\n"
|
|
80
|
+
"# Container-local liveness endpoint; never published by Compose.\n"
|
|
81
|
+
"http://127.0.0.1:2019 {\n"
|
|
82
|
+
"\trespond /healthz 200\n"
|
|
83
|
+
"}\n"
|
|
84
|
+
"\n"
|
|
85
|
+
f"{site} {{\n"
|
|
86
|
+
f"{tls_directive}"
|
|
87
|
+
"\tencode gzip\n"
|
|
88
|
+
f"\treverse_proxy {UPSTREAM}\n"
|
|
89
|
+
"}\n"
|
|
90
|
+
f"{ingest_site}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_caddyfile(root: Path, **kwargs) -> Path:
|
|
95
|
+
text = render_caddyfile(**kwargs)
|
|
96
|
+
path = root / "compose" / "Caddyfile"
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
path.write_text(text, encoding="utf-8")
|
|
99
|
+
# Host mounts make the internal root CA operator-accessible.
|
|
100
|
+
for directory in ("caddy-data", "caddy-config"):
|
|
101
|
+
(root / "tls" / directory).mkdir(parents=True, exist_ok=True)
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def tls_compose_env(tls_mode: str, public_url: str) -> dict[str, str]:
|
|
106
|
+
if tls_mode == "off":
|
|
107
|
+
return {}
|
|
108
|
+
return {
|
|
109
|
+
"VELTRO_TLS_MODE": tls_mode,
|
|
110
|
+
"PUBLIC_BASE_URL": public_url,
|
|
111
|
+
"VELTRO_TLS_UPSTREAM": UPSTREAM,
|
|
112
|
+
"VELTRO_TLS_INGEST_UPSTREAM": "chad-backend:8001",
|
|
113
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Minimal veltro.toml read/write (stdlib only, fixed key set)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, fields
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
11
|
+
|
|
12
|
+
VELTRO_TOML = "veltro.toml"
|
|
13
|
+
|
|
14
|
+
TLS_MODES = ("auto", "internal", "custom", "off")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class VeltroConfig:
|
|
19
|
+
products: str = "vectorflow,chad,warden"
|
|
20
|
+
public_url: str = "http://localhost"
|
|
21
|
+
tls_mode: str = "off"
|
|
22
|
+
dir: str = "."
|
|
23
|
+
installed_suite_version: str = ""
|
|
24
|
+
monitoring: bool = False
|
|
25
|
+
|
|
26
|
+
def validate(self) -> None:
|
|
27
|
+
from veltro_cli.lib.products import parse_products
|
|
28
|
+
|
|
29
|
+
parse_products(self.products)
|
|
30
|
+
if self.tls_mode not in TLS_MODES:
|
|
31
|
+
raise ValueError(f"tls_mode must be one of {TLS_MODES}, got {self.tls_mode!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_value(raw: str) -> Any:
|
|
38
|
+
raw = raw.strip()
|
|
39
|
+
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
|
|
40
|
+
inner = raw[1:-1]
|
|
41
|
+
if raw[0] == '"':
|
|
42
|
+
return (
|
|
43
|
+
inner.replace("\\n", "\n")
|
|
44
|
+
.replace("\\t", "\t")
|
|
45
|
+
.replace('\\"', '"')
|
|
46
|
+
.replace("\\\\", "\\")
|
|
47
|
+
)
|
|
48
|
+
return inner
|
|
49
|
+
if raw.lower() in ("true", "false"):
|
|
50
|
+
return raw.lower() == "true"
|
|
51
|
+
if re.fullmatch(r"-?\d+", raw):
|
|
52
|
+
return int(raw)
|
|
53
|
+
return raw
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_value(value: Any) -> str:
|
|
57
|
+
if isinstance(value, bool):
|
|
58
|
+
return "true" if value else "false"
|
|
59
|
+
if isinstance(value, int):
|
|
60
|
+
return str(value)
|
|
61
|
+
text = str(value)
|
|
62
|
+
escaped = (
|
|
63
|
+
text.replace("\\", "\\\\")
|
|
64
|
+
.replace('"', '\\"')
|
|
65
|
+
.replace("\n", "\\n")
|
|
66
|
+
.replace("\t", "\\t")
|
|
67
|
+
)
|
|
68
|
+
return f'"{escaped}"'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def loads(text: str) -> dict[str, Any]:
|
|
72
|
+
data: dict[str, Any] = {}
|
|
73
|
+
for line in text.splitlines():
|
|
74
|
+
stripped = line.strip()
|
|
75
|
+
if not stripped or stripped.startswith("#") or stripped.startswith("["):
|
|
76
|
+
continue
|
|
77
|
+
match = _KEY_RE.match(stripped)
|
|
78
|
+
if not match:
|
|
79
|
+
continue
|
|
80
|
+
data[match.group(1)] = _parse_value(match.group(2))
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def dumps(config: VeltroConfig | Mapping[str, Any]) -> str:
|
|
85
|
+
if isinstance(config, VeltroConfig):
|
|
86
|
+
payload = asdict(config)
|
|
87
|
+
else:
|
|
88
|
+
payload = dict(config)
|
|
89
|
+
lines = [
|
|
90
|
+
"# Generated by veltro-cli — do not hand-edit keys the CLI owns.",
|
|
91
|
+
"[veltro]",
|
|
92
|
+
]
|
|
93
|
+
for key in (
|
|
94
|
+
"products",
|
|
95
|
+
"public_url",
|
|
96
|
+
"tls_mode",
|
|
97
|
+
"dir",
|
|
98
|
+
"installed_suite_version",
|
|
99
|
+
"monitoring",
|
|
100
|
+
):
|
|
101
|
+
if key in payload and payload[key] is not None:
|
|
102
|
+
lines.append(f"{key} = {_format_value(payload[key])}")
|
|
103
|
+
lines.append("")
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_config(path: Path) -> VeltroConfig | None:
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
return None
|
|
110
|
+
raw = loads(path.read_text(encoding="utf-8"))
|
|
111
|
+
legacy_profile = raw.pop("profile", None)
|
|
112
|
+
if legacy_profile == "full":
|
|
113
|
+
raw.setdefault("products", "vectorflow,chad,warden")
|
|
114
|
+
elif legacy_profile is not None:
|
|
115
|
+
raise VeltroCliError(
|
|
116
|
+
f"legacy standalone profile {legacy_profile!r} cannot be upgraded in place",
|
|
117
|
+
remedy="import its users and product data into Veltro core plus the product, "
|
|
118
|
+
"then replace profile with products in veltro.toml",
|
|
119
|
+
)
|
|
120
|
+
known = {f.name for f in fields(VeltroConfig)}
|
|
121
|
+
kwargs = {k: v for k, v in raw.items() if k in known}
|
|
122
|
+
cfg = VeltroConfig(**kwargs)
|
|
123
|
+
cfg.validate()
|
|
124
|
+
return cfg
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_config(path: Path, config: VeltroConfig) -> None:
|
|
128
|
+
config.validate()
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
path.write_text(dumps(config), encoding="utf-8")
|