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 ADDED
@@ -0,0 +1,6 @@
1
+ """veltro-cli — stdlib-only operator entry point for the Veltro suite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.15.7"
6
+ __all__ = ["__version__"]
veltro_cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """python -m veltro_cli"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from veltro_cli.cli import main
6
+
7
+ raise SystemExit(main())
veltro_cli/cli.py ADDED
@@ -0,0 +1,478 @@
1
+ """veltro — single operator entry point (stdlib only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from veltro_cli import __version__
11
+ from veltro_cli.lib.agent import enroll_agent
12
+ from veltro_cli.lib.backup import cmd_backup, cmd_restore
13
+ from veltro_cli.lib.compose import ComposeClient
14
+ from veltro_cli.lib.demo import ACTIONS as DEMO_ACTIONS, run_demo
15
+ from veltro_cli.lib.doctor import run_doctor
16
+ from veltro_cli.lib.env_init import init_env
17
+ from veltro_cli.lib.errors import VeltroCliError, print_error
18
+ from veltro_cli.lib.install import install as run_install
19
+ from veltro_cli.lib.k8s import render as k8s_render
20
+ from veltro_cli.lib.paths import resolve_dir
21
+ from veltro_cli.lib.products import resolve_selection
22
+ from veltro_cli.lib.status import collect_status, format_status
23
+ from veltro_cli.lib.support import capture_support_bundle
24
+ from veltro_cli.lib.toml_config import (
25
+ TLS_MODES,
26
+ VELTRO_TOML,
27
+ VeltroConfig,
28
+ read_config,
29
+ )
30
+ from veltro_cli.lib.upgrade import execute_upgrade, plan_upgrade
31
+
32
+
33
+ def _load_ctx(args: argparse.Namespace) -> tuple[Path, str, bool, tuple[str, ...]]:
34
+ root = resolve_dir(getattr(args, "dir", None))
35
+ cfg = read_config(root / VELTRO_TOML)
36
+ products = resolve_selection(root, getattr(args, "products", None))
37
+ tls_mode = getattr(args, "tls", None) or (cfg.tls_mode if cfg else "off")
38
+ monitoring = bool(getattr(args, "monitoring", False) or (cfg.monitoring if cfg else False))
39
+ return root, tls_mode, monitoring, products
40
+
41
+
42
+ def _client(args: argparse.Namespace) -> ComposeClient:
43
+ root, tls_mode, monitoring, products = _load_ctx(args)
44
+ return ComposeClient(root, products=products, tls_mode=tls_mode, monitoring=monitoring)
45
+
46
+
47
+ def cmd_version(_args: argparse.Namespace) -> int:
48
+ print(f"veltro-cli {__version__}")
49
+ return 0
50
+
51
+
52
+ def cmd_init_env(args: argparse.Namespace) -> int:
53
+ root = resolve_dir(args.dir)
54
+ generated = init_env(root, force=bool(args.force))
55
+ print(f"wrote {root / '.env'}")
56
+ print(f" generated {len(generated)} secret(s): {' '.join(generated)}")
57
+ return 0
58
+
59
+
60
+ def cmd_install(args: argparse.Namespace) -> int:
61
+ source = Path(args.source).resolve() if args.source else None
62
+ dest = run_install(
63
+ products=args.products,
64
+ directory=args.dir,
65
+ public_url=args.public_url,
66
+ tls_mode=args.tls,
67
+ yes=bool(args.yes),
68
+ source=source,
69
+ release_url=args.release_url,
70
+ release_sha256=args.release_sha256,
71
+ suite_version=args.suite_version,
72
+ monitoring=bool(args.monitoring),
73
+ skip_up=bool(args.skip_up),
74
+ )
75
+ print(f"installed into {dest}")
76
+ shown_public_url = args.public_url or (read_config(dest / VELTRO_TOML) or VeltroConfig()).public_url
77
+ saved = read_config(dest / VELTRO_TOML) or VeltroConfig()
78
+ print(f" products={saved.products} tls={args.tls} public_url={shown_public_url}")
79
+ if args.tls == "internal":
80
+ print(
81
+ " trust CA after startup: "
82
+ f"{dest / 'tls/caddy-data/caddy/pki/authorities/local/root.crt'}"
83
+ )
84
+ print(" next: veltro status")
85
+ return 0
86
+
87
+
88
+ def cmd_up(args: argparse.Namespace) -> int:
89
+ client = _client(args)
90
+ compose_args = tuple(getattr(args, "compose_args", ()))
91
+ if args.dry_run:
92
+ client.up(*compose_args, dry_run=True)
93
+ for call in client.calls:
94
+ print(call.as_shell())
95
+ return 0
96
+ from veltro_cli.lib.install import start_stack
97
+
98
+ result = start_stack(client, *compose_args)
99
+ if result.stdout:
100
+ print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
101
+ if result.returncode != 0:
102
+ raise VeltroCliError(
103
+ result.stderr.strip() or "compose up failed",
104
+ remedy="run `veltro doctor` and `veltro logs`",
105
+ )
106
+ return 0
107
+
108
+
109
+ def cmd_down(args: argparse.Namespace) -> int:
110
+ client = _client(args)
111
+ result = client.down(*getattr(args, "compose_args", ()), dry_run=bool(args.dry_run))
112
+ if args.dry_run:
113
+ print(client.calls[-1].as_shell())
114
+ return 0
115
+ if result.returncode != 0:
116
+ raise VeltroCliError(result.stderr.strip() or "compose down failed")
117
+ return 0
118
+
119
+
120
+ def cmd_restart(args: argparse.Namespace) -> int:
121
+ client = _client(args)
122
+ result = client.restart(*getattr(args, "compose_args", ()), dry_run=bool(args.dry_run))
123
+ if args.dry_run:
124
+ print(client.calls[-1].as_shell())
125
+ return 0
126
+ if result.returncode != 0:
127
+ raise VeltroCliError(result.stderr.strip() or "compose restart failed")
128
+ return 0
129
+
130
+
131
+ def cmd_ps(args: argparse.Namespace) -> int:
132
+ client = _client(args)
133
+ result = client.ps(*getattr(args, "compose_args", ()), dry_run=bool(args.dry_run))
134
+ if args.dry_run:
135
+ print(client.calls[-1].as_shell())
136
+ return 0
137
+ sys.stdout.write(result.stdout)
138
+ if result.returncode != 0:
139
+ sys.stderr.write(result.stderr)
140
+ return result.returncode
141
+
142
+
143
+ def cmd_logs(args: argparse.Namespace) -> int:
144
+ client = _client(args)
145
+ result = client.logs(*args.service, dry_run=bool(args.dry_run))
146
+ if args.dry_run:
147
+ print(client.calls[-1].as_shell())
148
+ return 0
149
+ sys.stdout.write(result.stdout)
150
+ if result.returncode != 0:
151
+ sys.stderr.write(result.stderr)
152
+ return result.returncode
153
+
154
+
155
+ def cmd_config(args: argparse.Namespace) -> int:
156
+ client = _client(args)
157
+ compose_args = ("--format", args.format) if args.format else ()
158
+ result = client.config(*compose_args, dry_run=bool(args.dry_run))
159
+ if args.dry_run:
160
+ print(client.calls[-1].as_shell())
161
+ return 0
162
+ sys.stdout.write(result.stdout)
163
+ if result.returncode != 0:
164
+ sys.stderr.write(result.stderr)
165
+ return result.returncode
166
+
167
+ def cmd_pull(args: argparse.Namespace) -> int:
168
+ client = _client(args)
169
+ result = client.pull(*getattr(args, "compose_args", ()), dry_run=bool(args.dry_run))
170
+ if args.dry_run:
171
+ print(client.calls[-1].as_shell())
172
+ return 0
173
+ sys.stdout.write(result.stdout)
174
+ if result.returncode != 0:
175
+ sys.stderr.write(result.stderr)
176
+ return result.returncode
177
+
178
+
179
+ def cmd_status(args: argparse.Namespace) -> int:
180
+ root, tls_mode, monitoring, products = _load_ctx(args)
181
+ cfg = read_config(root / VELTRO_TOML)
182
+ public_url = args.public_url or (cfg.public_url if cfg else "http://localhost")
183
+ client = ComposeClient(root, products=products, tls_mode=tls_mode, monitoring=monitoring)
184
+
185
+ def fetcher(url: str, timeout: float = 3.0):
186
+ if args.dry_run:
187
+ return 200, '{"status":"ok"}'
188
+ from veltro_cli.lib.status import default_fetcher
189
+
190
+ return default_fetcher(url, timeout)
191
+
192
+ doc = collect_status(
193
+ root,
194
+ products=products,
195
+ public_url=public_url,
196
+ client=client,
197
+ fetcher=fetcher,
198
+ tls_mode=tls_mode,
199
+ check_latest=not args.dry_run,
200
+ )
201
+ sys.stdout.write(format_status(doc))
202
+ bad = any(p["state"] not in {"ready", "not_installed"} for p in doc.get("probes") or [])
203
+ return 1 if bad and not args.dry_run else 0
204
+
205
+
206
+ def cmd_doctor(args: argparse.Namespace) -> int:
207
+ return run_doctor(
208
+ directory=args.dir,
209
+ products=args.products,
210
+ public_url=args.public_url,
211
+ http_port=int(args.http_port),
212
+ )
213
+
214
+
215
+ def cmd_demo(args: argparse.Namespace) -> int:
216
+ return run_demo(
217
+ args.action,
218
+ directory=args.dir,
219
+ datasets=args.dataset,
220
+ extra_args=args.seed_arg,
221
+ )
222
+
223
+
224
+ def cmd_agent(args: argparse.Namespace) -> int:
225
+ client = _client(args)
226
+ return enroll_agent(
227
+ directory=args.dir,
228
+ environment=args.environment,
229
+ client=client,
230
+ dry_run=bool(args.dry_run),
231
+ )
232
+
233
+
234
+ def cmd_upgrade(args: argparse.Namespace) -> int:
235
+ root = resolve_dir(args.dir)
236
+ cfg = read_config(root / VELTRO_TOML)
237
+ current = (cfg.installed_suite_version if cfg else "") or "0.0.0"
238
+ if not args.to:
239
+ print(f"installed: {current}")
240
+ print("pass --to X.Y.Z to plan/execute an upgrade")
241
+ return 0
242
+ plan = plan_upgrade(directory=root, target=args.to, current=current)
243
+ print(f"upgrade plan: {plan['from']} → {plan['to']}")
244
+ for delta in plan["module_deltas"]:
245
+ print(f" {delta['module']}: {delta['from']} → {delta['to']}")
246
+ for warning in plan["warnings"]:
247
+ print(f" WARNING: {warning}")
248
+ for step in plan["steps"]:
249
+ print(f" - {step}")
250
+ if args.dry_run:
251
+ return 0
252
+ execute_upgrade(directory=root, target=args.to, current=current)
253
+ print(f"upgraded to {args.to}")
254
+ return 0
255
+
256
+
257
+ def cmd_k8s(args: argparse.Namespace) -> int:
258
+ if args.k8s_command != "render":
259
+ raise VeltroCliError(f"unknown k8s subcommand {args.k8s_command!r}")
260
+ products = resolve_selection(resolve_dir(getattr(args, "dir", None)), args.products)
261
+ out = k8s_render(
262
+ products=products,
263
+ values=args.values or (),
264
+ dry_run=bool(args.dry_run),
265
+ )
266
+ print(out)
267
+ return 0
268
+
269
+
270
+ def cmd_support_bundle(args: argparse.Namespace) -> int:
271
+ path = capture_support_bundle(
272
+ directory=args.dir,
273
+ output=args.output,
274
+ base_url=args.base_url,
275
+ )
276
+ print(path)
277
+ return 0
278
+
279
+
280
+ def build_parser() -> argparse.ArgumentParser:
281
+ parser = argparse.ArgumentParser(
282
+ prog="veltro",
283
+ description="Operator CLI for the Veltro security operations suite.",
284
+ )
285
+ parser.add_argument("--version", action="store_true", help="print CLI version")
286
+ parser.add_argument("--dir", default=None, help="install / checkout directory (default: discover or ./veltro)")
287
+ parser.add_argument("--dry-run", action="store_true", help="print actions without running docker")
288
+
289
+ sub = parser.add_subparsers(dest="command")
290
+
291
+ p_install = sub.add_parser("install", help="download/materialise and start Veltro")
292
+ p_install.add_argument("--products", default=None, help="comma-separated subset of vectorflow,chad,warden; empty for core")
293
+ p_install.add_argument("--dir", default="./veltro")
294
+ p_install.add_argument("--public-url", default=None)
295
+ p_install.add_argument("--tls", choices=TLS_MODES, default="off")
296
+ p_install.add_argument("--yes", action="store_true")
297
+ p_install.add_argument("--source", default=None, help="path to a Veltro git checkout to copy from")
298
+ p_install.add_argument("--release-url", default=None, help="URL of a release tarball")
299
+ p_install.add_argument("--release-sha256", default=None, help="required SHA-256 for --release-url")
300
+ p_install.add_argument(
301
+ "--suite-version",
302
+ default=None,
303
+ help="Veltro release to install, X.Y.Z (default: the newest release)",
304
+ )
305
+ p_install.add_argument("--monitoring", action="store_true")
306
+ p_install.add_argument("--skip-up", action="store_true", help="materialise files only (tests)")
307
+ p_install.set_defaults(func=cmd_install)
308
+
309
+ def _lifecycle(name: str, func, help_text: str, *, passthrough: bool = False):
310
+ p = sub.add_parser(name, help=help_text)
311
+ p.add_argument("--products", default=None)
312
+ p.add_argument("--tls", choices=TLS_MODES, default=None)
313
+ p.add_argument("--monitoring", action="store_true")
314
+ p.add_argument("--dir", default=None)
315
+ p.add_argument("--dry-run", action="store_true")
316
+ if passthrough:
317
+ p.add_argument("compose_args", nargs=argparse.REMAINDER)
318
+ p.set_defaults(func=func)
319
+ return p
320
+
321
+ _lifecycle("up", cmd_up, "start the stack (compose up -d --wait)", passthrough=True)
322
+ _lifecycle("down", cmd_down, "stop containers (keeps volumes)", passthrough=True)
323
+ _lifecycle("restart", cmd_restart, "restart services", passthrough=True)
324
+ _lifecycle("ps", cmd_ps, "show service status", passthrough=True)
325
+ _lifecycle("pull", cmd_pull, "pull pinned images", passthrough=True)
326
+ p_logs = _lifecycle("logs", cmd_logs, "follow logs")
327
+ p_logs.add_argument("service", nargs="*")
328
+ p_config = _lifecycle("config", cmd_config, "render resolved compose config")
329
+ p_config.add_argument("--format", choices=("yaml", "json"), default=None)
330
+
331
+ p_status = sub.add_parser("status", help="compose ps + HTTP health probes")
332
+ p_status.add_argument("--products", default=None)
333
+ p_status.add_argument("--tls", choices=TLS_MODES, default=None)
334
+ p_status.add_argument("--dir", default=None)
335
+ p_status.add_argument("--public-url", default=None)
336
+ p_status.add_argument("--dry-run", action="store_true")
337
+ p_status.set_defaults(func=cmd_status)
338
+
339
+ p_doctor = sub.add_parser("doctor", help="host and install preflight checks")
340
+ p_doctor.add_argument("--products", default=None)
341
+ p_doctor.add_argument("--dir", default=None)
342
+ p_doctor.add_argument("--public-url", default=None)
343
+ p_doctor.add_argument("--http-port", default="80")
344
+ p_doctor.set_defaults(func=cmd_doctor)
345
+
346
+ p_demo = sub.add_parser("demo", help="list, seed or reset the demo sandbox datasets")
347
+ p_demo.add_argument("action", choices=DEMO_ACTIONS)
348
+ p_demo.add_argument(
349
+ "--dataset",
350
+ action="append",
351
+ default=[],
352
+ metavar="ID",
353
+ help="dataset id from content/datasets (repeatable, comma-separated, or `all`); defaults to brute-force on seed",
354
+ )
355
+ p_demo.add_argument("--dir", default=None)
356
+ # A trailing REMAINDER positional would swallow `--dataset` itself, so the
357
+ # golden-path seeder's extra arguments are passed one at a time.
358
+ p_demo.add_argument("--seed-arg", action="append", default=[], metavar="ARG",
359
+ help="extra argument forwarded to scripts/seed-demo.sh (repeatable)")
360
+ p_demo.set_defaults(func=cmd_demo)
361
+
362
+ p_agent = sub.add_parser("agent", help="agent enrollment helpers")
363
+ agent_sub = p_agent.add_subparsers(dest="agent_command")
364
+ p_enroll = agent_sub.add_parser("enroll", help="start vf-agent profile and prepare token dir")
365
+ p_enroll.add_argument("--environment", default=None)
366
+ p_enroll.add_argument("--dir", default=None)
367
+ p_enroll.add_argument("--products", default=None)
368
+ p_enroll.add_argument("--dry-run", action="store_true")
369
+ p_enroll.set_defaults(func=cmd_agent)
370
+
371
+ p_upgrade = sub.add_parser("upgrade", help="upgrade to a newer suite release")
372
+ p_upgrade.add_argument("--to", default=None, help="Veltro release to upgrade to, X.Y.Z")
373
+ p_upgrade.add_argument("--dry-run", action="store_true")
374
+ p_upgrade.add_argument("--dir", default=None)
375
+ p_upgrade.set_defaults(func=cmd_upgrade)
376
+
377
+ p_backup = sub.add_parser("backup", help="create a suite backup (§1.5)")
378
+ p_backup.add_argument("--to", default=None)
379
+ p_backup.add_argument("--auto", action="store_true")
380
+ p_backup.add_argument("--products", default=None)
381
+ p_backup.add_argument("--include-env", action="store_true")
382
+ p_backup.add_argument("--passphrase", default=None)
383
+ p_backup.add_argument("--skip-optional", action="store_true")
384
+ p_backup.add_argument("--schedule", default=None)
385
+ p_backup.add_argument("--backup-dir", default=None)
386
+ p_backup.add_argument("--keep", type=int, default=7)
387
+ p_backup.add_argument("--dir", default=None)
388
+ p_backup.set_defaults(func=cmd_backup)
389
+
390
+ p_restore = sub.add_parser("restore", help="restore a suite backup (§1.5)")
391
+ p_restore.add_argument("path", nargs="?")
392
+ p_restore.add_argument("--yes", action="store_true")
393
+ p_restore.add_argument("--products", default=None)
394
+ p_restore.add_argument("--passphrase", default=None)
395
+ p_restore.add_argument("--dry-run", action="store_true")
396
+ p_restore.add_argument("--skip-start", action="store_true")
397
+ p_restore.add_argument("--dir", default=None)
398
+ p_restore.set_defaults(func=cmd_restore)
399
+
400
+ p_k8s = sub.add_parser("k8s", help="kubernetes helpers")
401
+ k8s_sub = p_k8s.add_subparsers(dest="k8s_command")
402
+ p_render = k8s_sub.add_parser("render", help="helm template the umbrella chart")
403
+ p_render.add_argument(
404
+ "--products",
405
+ default=None,
406
+ help="comma-separated subset of vectorflow,chad,warden on the core; empty for core only",
407
+ )
408
+ p_render.add_argument("--values", action="append", default=[])
409
+ p_render.add_argument("--dry-run", action="store_true")
410
+ p_render.set_defaults(func=cmd_k8s)
411
+
412
+ p_sb = sub.add_parser("support-bundle", help="capture a secret-free support bundle")
413
+ p_sb.add_argument("--dir", default=None)
414
+ p_sb.add_argument("--output", default=None)
415
+ p_sb.add_argument("--base-url", default=None)
416
+ p_sb.set_defaults(func=cmd_support_bundle)
417
+
418
+ p_init = sub.add_parser("init-env", help="bootstrap .env from .env.example")
419
+ p_init.add_argument("--dir", default=None)
420
+ p_init.add_argument("--force", action="store_true")
421
+ p_init.set_defaults(func=cmd_init_env)
422
+
423
+ return parser
424
+
425
+
426
+ def main(argv: Sequence[str] | None = None) -> int:
427
+ argv_list = list(sys.argv[1:] if argv is None else argv)
428
+ parser = build_parser()
429
+ if any(arg == "--profile" or arg.startswith("--profile=") for arg in argv_list):
430
+ print("error: --profile was removed; select products with --products or VELTRO_PRODUCTS", file=sys.stderr)
431
+ return 2
432
+ try:
433
+ args, unknown = parser.parse_known_args(argv_list)
434
+ if unknown:
435
+ if getattr(args, "command", None) in {"up", "down", "restart", "ps", "pull"}:
436
+ args.compose_args = [*getattr(args, "compose_args", ()), *unknown]
437
+ elif getattr(args, "command", None) == "logs":
438
+ args.service = [*getattr(args, "service", ()), *unknown]
439
+ else:
440
+ parser.error("unrecognized arguments: " + " ".join(unknown))
441
+ except SystemExit as exc:
442
+ return int(exc.code or 0)
443
+
444
+ # Top-level --dir / --dry-run should win when a subcommand left defaults.
445
+ # argparse subparsers do not inherit parent option values.
446
+ if hasattr(args, "dir") and args.dir is None:
447
+ parent = argparse.ArgumentParser(add_help=False)
448
+ parent.add_argument("--dir", default=None)
449
+ parent.add_argument("--version", action="store_true")
450
+ parent.add_argument("--dry-run", action="store_true")
451
+ parent_args, _ = parent.parse_known_args(argv_list)
452
+ if parent_args.dir is not None:
453
+ args.dir = parent_args.dir
454
+ if parent_args.dry_run and not getattr(args, "dry_run", False):
455
+ args.dry_run = True
456
+
457
+ if getattr(args, "version", False) and not getattr(args, "command", None):
458
+ return cmd_version(args)
459
+
460
+ func = getattr(args, "func", None)
461
+ if func is None:
462
+ if getattr(args, "version", False):
463
+ return cmd_version(args)
464
+ parser.print_help()
465
+ return 2
466
+ try:
467
+ return int(func(args))
468
+ except VeltroCliError as exc:
469
+ return print_error(exc)
470
+ except KeyboardInterrupt:
471
+ print("interrupted", file=sys.stderr)
472
+ return 130
473
+ except Exception as exc: # noqa: BLE001
474
+ return print_error(exc)
475
+
476
+
477
+ if __name__ == "__main__":
478
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """Library pieces shared by the CLI and thin script shims."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,37 @@
1
+ """veltro agent enroll — agent-up + token drop ceremony."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from veltro_cli.lib.compose import ComposeClient
8
+ from veltro_cli.lib.errors import VeltroCliError
9
+ from veltro_cli.lib.paths import enrollment_token_dir, resolve_dir
10
+
11
+
12
+ def enroll_agent(
13
+ *,
14
+ directory: str | Path | None = None,
15
+ environment: str | None = None,
16
+ client: ComposeClient | None = None,
17
+ dry_run: bool = False,
18
+ ) -> int:
19
+ root = resolve_dir(directory)
20
+ client = client or ComposeClient(root)
21
+ token_dir = enrollment_token_dir(root)
22
+ if not dry_run:
23
+ token_dir.mkdir(parents=True, exist_ok=True)
24
+ result = client.agent_up(dry_run=dry_run)
25
+ if result.returncode != 0:
26
+ raise VeltroCliError(
27
+ f"agent-up failed: {result.stderr.strip() or result.stdout.strip()}",
28
+ remedy="ensure the suite is up and an enrollment token will be dropped into "
29
+ f"{token_dir} (see README 'VectorFlow agent enrollment')",
30
+ )
31
+ # Token minting is owned by the seeder / VF API; we only start the agent profile.
32
+ if environment:
33
+ # Record intent for operators; actual enroll uses the token file.
34
+ marker = token_dir / "environment-name"
35
+ if not dry_run:
36
+ marker.write_text(environment + "\n", encoding="utf-8")
37
+ return 0
@@ -0,0 +1,139 @@
1
+ """Suite backup / restore command handlers (plan §1.5).
2
+
3
+ The implementation lives in ``scripts/veltro_backup.py`` so thin shims and the
4
+ operator CLI share one stdlib module. This adapter translates ``veltro backup``
5
+ / ``veltro restore`` argparse namespaces into that library and maps failures to
6
+ ``VeltroCliError``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from veltro_cli.lib.errors import VeltroCliError
17
+ from veltro_cli.lib.paths import resolve_dir
18
+
19
+ # scripts/veltro_backup.py remains the single DR implementation. The installed
20
+ # CLI resolves it from the selected suite directory; the source checkout paths
21
+ # keep editable installs working.
22
+ _HERE = Path(__file__).resolve()
23
+
24
+
25
+ def _load_veltro_backup(root: Path):
26
+ import importlib.util
27
+
28
+ candidates = (
29
+ root / "scripts",
30
+ _HERE.parents[3] / "scripts",
31
+ _HERE.parents[2] / "scripts",
32
+ Path.cwd() / "scripts",
33
+ )
34
+ for scripts_dir in candidates:
35
+ path = scripts_dir / "veltro_backup.py"
36
+ if path.is_file():
37
+ spec = importlib.util.spec_from_file_location("veltro_backup", path)
38
+ if spec is None or spec.loader is None:
39
+ continue
40
+ mod = importlib.util.module_from_spec(spec)
41
+ sys.modules["veltro_backup"] = mod
42
+ spec.loader.exec_module(mod)
43
+ return mod
44
+ try:
45
+ import veltro_backup as mod # type: ignore
46
+
47
+ return mod
48
+ except ImportError as exc: # pragma: no cover
49
+ raise VeltroCliError(
50
+ "veltro_backup library not found",
51
+ remedy="run from a Veltro checkout that contains scripts/veltro_backup.py",
52
+ ) from exc
53
+
54
+
55
+ def cmd_backup(args: Any) -> int:
56
+ """``veltro backup [--to PATH] [--auto] [--dir DIR]``."""
57
+ root = resolve_dir(getattr(args, "dir", None))
58
+ vb = _load_veltro_backup(root)
59
+ products = getattr(args, "products", None)
60
+ include_env = bool(getattr(args, "include_env", False))
61
+ passphrase = getattr(args, "passphrase", None)
62
+ schedule = getattr(args, "schedule", None)
63
+ if schedule:
64
+ sys.stdout.write(
65
+ vb.schedule_snippet(
66
+ schedule,
67
+ backup_dir=getattr(args, "backup_dir", None) or "/var/lib/veltro/backups",
68
+ keep=int(getattr(args, "keep", 7) or 7),
69
+ )
70
+ )
71
+ sys.stdout.write(
72
+ "Enable compose profile `backup` (compose/backup.yml) after writing crontab.\n"
73
+ )
74
+ return 0
75
+
76
+ output = getattr(args, "to", None)
77
+ if output is None and getattr(args, "auto", False):
78
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
79
+ output = str(root / "backups" / f"veltro-backup-auto-{stamp}.tar")
80
+ Path(output).parent.mkdir(parents=True, exist_ok=True)
81
+
82
+ options = vb.BackupOptions(
83
+ root=root,
84
+ products=products,
85
+ output=Path(output) if output else None,
86
+ include_env=include_env,
87
+ passphrase=passphrase,
88
+ skip_optional=bool(getattr(args, "skip_optional", False)),
89
+ )
90
+ try:
91
+ path = vb.create_backup(options)
92
+ except vb.BackupError as exc:
93
+ raise VeltroCliError(
94
+ str(exc),
95
+ remedy="see docs/runbooks/backup-restore.md; ensure selected products are up and VELTRO_BACKUP_PASSPHRASE is set when keyrings exist",
96
+ ) from exc
97
+ print(f"wrote {path}")
98
+ return 0
99
+
100
+
101
+ def cmd_restore(args: Any) -> int:
102
+ """``veltro restore PATH [--yes] [--dir DIR]``."""
103
+
104
+ archive = getattr(args, "path", None) or getattr(args, "archive", None)
105
+ if not archive:
106
+ raise VeltroCliError(
107
+ "restore requires an archive path",
108
+ remedy="veltro restore ./veltro-backup-….tar --yes",
109
+ )
110
+ root = resolve_dir(getattr(args, "dir", None))
111
+ vb = _load_veltro_backup(root)
112
+ options = vb.RestoreOptions(
113
+ root=root,
114
+ archive=Path(archive),
115
+ products=getattr(args, "products", None),
116
+ passphrase=getattr(args, "passphrase", None),
117
+ dry_run=bool(getattr(args, "dry_run", False)),
118
+ yes=bool(getattr(args, "yes", False)),
119
+ skip_start=bool(getattr(args, "skip_start", False)),
120
+ )
121
+ try:
122
+ result = vb.run_restore(options)
123
+ except vb.BackupError as exc:
124
+ raise VeltroCliError(
125
+ str(exc),
126
+ remedy="run with --dry-run to inspect the plan; refuse rules are documented in docs/runbooks/backup-restore.md",
127
+ ) from exc
128
+ import json
129
+
130
+ print(json.dumps(result, indent=2, sort_keys=True))
131
+ if result.get("status") == "restored":
132
+ return 0
133
+ if result.get("executable") is False:
134
+ return 2
135
+ return 0
136
+
137
+
138
+
139
+ __all__ = ["cmd_backup", "cmd_restore"]