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,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")