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,276 @@
1
+ """Compose argv derivation shared by the CLI and Makefile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Callable, Mapping, Sequence
11
+
12
+ from veltro_cli.lib.errors import VeltroCliError
13
+ from veltro_cli.lib.paths import enrollment_token_dir
14
+ from veltro_cli.lib.products import PRODUCTS
15
+ from veltro_cli.lib.release import is_release_tag
16
+ from veltro_cli.lib.toml_config import TLS_MODES, VELTRO_TOML, read_config
17
+
18
+ Runner = Callable[[Sequence[str], Mapping[str, str] | None, Path | None], "RunResult"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RunResult:
23
+ returncode: int
24
+ stdout: str = ""
25
+ stderr: str = ""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ComposeInvocation:
30
+ """Exact process environment + argv that should be executed."""
31
+
32
+ argv: tuple[str, ...]
33
+ env: dict[str, str] = field(default_factory=dict)
34
+ cwd: str | None = None
35
+
36
+ def as_shell(self) -> str:
37
+ parts: list[str] = []
38
+ for key in sorted(self.env):
39
+ parts.append(f"{key}={_shell_quote(self.env[key])}")
40
+ parts.extend(_shell_quote(a) for a in self.argv)
41
+ return " ".join(parts)
42
+
43
+
44
+ def _shell_quote(value: str) -> str:
45
+ if not value or re_needs_quote(value):
46
+ return "'" + value.replace("'", "'\"'\"'") + "'"
47
+ return value
48
+
49
+
50
+ def re_needs_quote(value: str) -> bool:
51
+ return any(ch in value for ch in " \t\n\"'$&|<>;`\\*?[]{}()#!")
52
+
53
+
54
+ def profile_flags(
55
+ products: tuple[str, ...],
56
+ *, inspect: bool = False, tls_mode: str = "off", monitoring: bool = False,
57
+ ) -> list[str]:
58
+ unknown = set(products) - set(PRODUCTS)
59
+ if unknown:
60
+ raise VeltroCliError(f"unknown products: {', '.join(sorted(unknown))}")
61
+ flags = ["--profile", "core"]
62
+ for product in PRODUCTS:
63
+ if product in products:
64
+ flags.extend(["--profile", product])
65
+ if "chad" in products:
66
+ flags.extend(["--profile", "opensearch"])
67
+ if inspect and "vectorflow" in products:
68
+ flags.extend(["--profile", "agent"])
69
+ if tls_mode and tls_mode != "off":
70
+ if tls_mode not in TLS_MODES:
71
+ raise VeltroCliError(
72
+ f"unknown tls mode {tls_mode!r}",
73
+ remedy=f"choose one of: {', '.join(TLS_MODES)}",
74
+ )
75
+ flags.extend(["--profile", "tls"])
76
+ if monitoring:
77
+ flags.extend(["--profile", "monitoring"])
78
+ return flags
79
+
80
+
81
+ def compose_env(
82
+ products: tuple[str, ...],
83
+ *, environ: Mapping[str, str] | None = None,
84
+ ) -> dict[str, str]:
85
+ """Environment variables the Makefile prefixes onto the compose process."""
86
+ base = dict(environ if environ is not None else os.environ)
87
+ env: dict[str, str] = {"VELTRO_PRODUCTS": ",".join(products)}
88
+ if "chad" in products:
89
+ env["OPENSEARCH_SEED_URL"] = base.get("OPENSEARCH_SEED_URL") or "https://opensearch:9200"
90
+ return env
91
+
92
+
93
+ def compose_base_argv(
94
+ root: Path,
95
+ *,
96
+ versions_file: str = "versions.env",
97
+ env_file: str = ".env",
98
+ ) -> list[str]:
99
+ return [
100
+ "docker",
101
+ "compose",
102
+ "--env-file",
103
+ str(root / versions_file) if not Path(versions_file).is_absolute() else versions_file,
104
+ "--env-file",
105
+ str(root / env_file) if not Path(env_file).is_absolute() else env_file,
106
+ ]
107
+
108
+
109
+ def build_compose_invocation(
110
+ root: Path,
111
+ products: tuple[str, ...],
112
+ compose_args: Sequence[str],
113
+ *,
114
+ inspect: bool = False,
115
+ tls_mode: str = "off",
116
+ monitoring: bool = False,
117
+ environ: Mapping[str, str] | None = None,
118
+ extra_env: Mapping[str, str] | None = None,
119
+ ) -> ComposeInvocation:
120
+ """Build the exact argv + env matching `make <target> PRODUCTS=…`."""
121
+ argv = compose_base_argv(root)
122
+ argv.extend(profile_flags(products, inspect=inspect, tls_mode=tls_mode, monitoring=monitoring))
123
+ argv.extend(compose_args)
124
+ env = compose_env(products, environ=environ)
125
+ if extra_env:
126
+ env.update(dict(extra_env))
127
+ return ComposeInvocation(argv=tuple(argv), env=env, cwd=str(root))
128
+
129
+
130
+ def default_runner(
131
+ argv: Sequence[str],
132
+ env: Mapping[str, str] | None = None,
133
+ cwd: Path | None = None,
134
+ ) -> RunResult:
135
+ merged = dict(os.environ)
136
+ if env:
137
+ merged.update(env)
138
+ try:
139
+ completed = subprocess.run(
140
+ list(argv),
141
+ cwd=str(cwd) if cwd else None,
142
+ env=merged,
143
+ capture_output=True,
144
+ text=True,
145
+ check=False,
146
+ )
147
+ except FileNotFoundError as exc:
148
+ raise VeltroCliError(
149
+ f"executable not found: {argv[0]}",
150
+ remedy="install Docker Desktop / Engine and ensure `docker` is on PATH",
151
+ ) from exc
152
+ return RunResult(completed.returncode, completed.stdout or "", completed.stderr or "")
153
+
154
+ def streaming_runner(
155
+ argv: Sequence[str],
156
+ env: Mapping[str, str] | None = None,
157
+ cwd: Path | None = None,
158
+ ) -> RunResult:
159
+ """Run a long-lived command with the caller's terminal attached."""
160
+ merged = dict(os.environ)
161
+ if env:
162
+ merged.update(env)
163
+ try:
164
+ completed = subprocess.run(
165
+ list(argv),
166
+ cwd=str(cwd) if cwd else None,
167
+ env=merged,
168
+ check=False,
169
+ )
170
+ except FileNotFoundError as exc:
171
+ raise VeltroCliError(
172
+ f"executable not found: {argv[0]}",
173
+ remedy="install Docker Desktop / Engine and ensure `docker` is on PATH",
174
+ ) from exc
175
+ return RunResult(completed.returncode)
176
+
177
+
178
+ class ComposeClient:
179
+ """Thin wrapper that records or runs compose invocations."""
180
+
181
+ def __init__(
182
+ self,
183
+ root: Path,
184
+ *,
185
+ products: tuple[str, ...] = PRODUCTS,
186
+ tls_mode: str = "off",
187
+ monitoring: bool = False,
188
+ runner: Runner | None = None,
189
+ stream_runner: Runner | None = None,
190
+ ) -> None:
191
+ self.root = root
192
+ self.products = products
193
+ self.tls_mode = tls_mode
194
+ self.monitoring = monitoring
195
+ self.runner = runner or default_runner
196
+ self.stream_runner = stream_runner or streaming_runner
197
+ self.calls: list[ComposeInvocation] = []
198
+
199
+ def invoke(
200
+ self,
201
+ compose_args: Sequence[str],
202
+ *,
203
+ inspect: bool = False,
204
+ extra_env: Mapping[str, str] | None = None,
205
+ dry_run: bool = False,
206
+ inherit_stdio: bool = False,
207
+ ) -> RunResult:
208
+ runtime_env = dict(extra_env or {})
209
+ config = read_config(self.root / VELTRO_TOML)
210
+ if config and is_release_tag(config.installed_suite_version):
211
+ runtime_env.setdefault("VELTRO_SUITE_VERSION", config.installed_suite_version)
212
+ inv = build_compose_invocation(
213
+ self.root,
214
+ self.products,
215
+ compose_args,
216
+ inspect=inspect,
217
+ tls_mode=self.tls_mode,
218
+ monitoring=self.monitoring,
219
+ extra_env=runtime_env,
220
+ )
221
+ self.calls.append(inv)
222
+ if dry_run:
223
+ return RunResult(0, inv.as_shell() + "\n", "")
224
+ merged_env = dict(os.environ)
225
+ merged_env.update(inv.env)
226
+ runner = self.stream_runner if inherit_stdio else self.runner
227
+ return runner(inv.argv, merged_env, Path(inv.cwd) if inv.cwd else None)
228
+
229
+ def up(self, *extra: str, wait: bool = True, dry_run: bool = False) -> RunResult:
230
+ args = ["up", "-d"]
231
+ if wait:
232
+ args.append("--wait")
233
+ args.extend(extra)
234
+ return self.invoke(args, dry_run=dry_run)
235
+
236
+ def down(self, *extra: str, dry_run: bool = False) -> RunResult:
237
+ return self.invoke(("down", *extra), inspect=True, dry_run=dry_run)
238
+
239
+ def ps(self, *extra: str, dry_run: bool = False) -> RunResult:
240
+ return self.invoke(("ps", *extra), inspect=True, dry_run=dry_run)
241
+
242
+ def logs(self, *extra: str, dry_run: bool = False) -> RunResult:
243
+ args = ["logs", "-f", "--tail=200", *extra]
244
+ return self.invoke(args, inspect=True, dry_run=dry_run, inherit_stdio=not dry_run)
245
+
246
+ def config(self, *extra: str, dry_run: bool = False) -> RunResult:
247
+ return self.invoke(("config", *extra), dry_run=dry_run)
248
+
249
+ def pull(self, *extra: str, dry_run: bool = False) -> RunResult:
250
+ return self.invoke(("pull", *extra), inspect=True, dry_run=dry_run)
251
+
252
+ def restart(self, *extra: str, dry_run: bool = False) -> RunResult:
253
+ return self.invoke(("restart", *extra), inspect=True, dry_run=dry_run)
254
+
255
+ def agent_up(self, *extra: str, dry_run: bool = False) -> RunResult:
256
+ token_dir = enrollment_token_dir(self.root)
257
+ if not dry_run:
258
+ token_dir.mkdir(parents=True, exist_ok=True)
259
+ os.chmod(token_dir, 0o700)
260
+ # agent profile only — matches Makefile agent-up (no --wait).
261
+ argv = compose_base_argv(self.root) + ["--profile", "agent", "up", "-d", *extra]
262
+ env = compose_env(self.products)
263
+ env["VF_ENROLLMENT_TOKEN_DIR"] = str(token_dir)
264
+ inv = ComposeInvocation(argv=tuple(argv), env=env, cwd=str(self.root))
265
+ self.calls.append(inv)
266
+ if dry_run:
267
+ return RunResult(0, inv.as_shell() + "\n", "")
268
+ merged = dict(os.environ)
269
+ merged.update(inv.env)
270
+ return self.runner(inv.argv, merged, self.root)
271
+
272
+
273
+
274
+
275
+ def which_docker() -> str | None:
276
+ return shutil.which("docker")