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/doctor.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""veltro doctor — host and install preflight."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from veltro_cli.lib.host import (
|
|
10
|
+
CheckResult,
|
|
11
|
+
check_clock_skew,
|
|
12
|
+
check_compose,
|
|
13
|
+
check_disk,
|
|
14
|
+
check_docker,
|
|
15
|
+
check_env_file,
|
|
16
|
+
check_host_minimums,
|
|
17
|
+
check_port_free,
|
|
18
|
+
check_public_url_dns,
|
|
19
|
+
check_python,
|
|
20
|
+
check_versions_env,
|
|
21
|
+
check_vm_max_map_count,
|
|
22
|
+
)
|
|
23
|
+
from veltro_cli.lib.paths import resolve_dir
|
|
24
|
+
from veltro_cli.lib.products import resolve_selection
|
|
25
|
+
from veltro_cli.lib.toml_config import VELTRO_TOML, read_config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_doctor(
|
|
29
|
+
*,
|
|
30
|
+
directory: str | Path | None = None,
|
|
31
|
+
products: str | None = None,
|
|
32
|
+
public_url: str | None = None,
|
|
33
|
+
http_port: int = 80,
|
|
34
|
+
stream: TextIO | None = None,
|
|
35
|
+
) -> int:
|
|
36
|
+
out = stream or sys.stdout
|
|
37
|
+
root = resolve_dir(directory)
|
|
38
|
+
cfg = read_config(root / VELTRO_TOML)
|
|
39
|
+
selected = resolve_selection(root, products)
|
|
40
|
+
public_url = public_url or (cfg.public_url if cfg else "http://localhost")
|
|
41
|
+
|
|
42
|
+
checks: list[CheckResult] = [
|
|
43
|
+
check_python(),
|
|
44
|
+
check_docker(),
|
|
45
|
+
check_compose(),
|
|
46
|
+
check_disk(root),
|
|
47
|
+
*check_host_minimums(),
|
|
48
|
+
check_vm_max_map_count(required=("chad" in selected)),
|
|
49
|
+
check_port_free(http_port),
|
|
50
|
+
check_versions_env(root),
|
|
51
|
+
check_env_file(root),
|
|
52
|
+
check_public_url_dns(public_url),
|
|
53
|
+
check_clock_skew(),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# Identity is present even when no products are selected.
|
|
57
|
+
if (root / ".env").is_file():
|
|
58
|
+
checks.append(_identity_credentials_check(root, selected))
|
|
59
|
+
|
|
60
|
+
worst = 0
|
|
61
|
+
for check in checks:
|
|
62
|
+
mark = {"pass": "PASS", "warn": "WARN", "fail": "FAIL"}[check.status]
|
|
63
|
+
line = f"[{mark}] {check.name}: {check.detail}"
|
|
64
|
+
if check.remedy and check.status != "pass":
|
|
65
|
+
line += f"\n fix: {check.remedy}"
|
|
66
|
+
print(line, file=out)
|
|
67
|
+
if check.status == "fail":
|
|
68
|
+
worst = 1
|
|
69
|
+
return worst
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _identity_credentials_check(root: Path, products: tuple[str, ...] | None = None) -> CheckResult:
|
|
73
|
+
scripts = root / "scripts"
|
|
74
|
+
if str(scripts) not in sys.path:
|
|
75
|
+
sys.path.insert(0, str(scripts))
|
|
76
|
+
try:
|
|
77
|
+
import validate_identity_credentials as vic # type: ignore
|
|
78
|
+
except ImportError:
|
|
79
|
+
return CheckResult(
|
|
80
|
+
"identity_credentials",
|
|
81
|
+
"warn",
|
|
82
|
+
"validate_identity_credentials.py not importable",
|
|
83
|
+
)
|
|
84
|
+
env_file = root / ".env"
|
|
85
|
+
missing = vic.missing(env_file, {}, products=products)
|
|
86
|
+
problems = (
|
|
87
|
+
list(missing)
|
|
88
|
+
+ vic.dsn_problems(env_file, {})
|
|
89
|
+
+ vic.session_secret_problems(env_file, {})
|
|
90
|
+
+ vic.signing_passphrase_problems(env_file, {})
|
|
91
|
+
)
|
|
92
|
+
if problems:
|
|
93
|
+
return CheckResult(
|
|
94
|
+
"identity_credentials",
|
|
95
|
+
"fail",
|
|
96
|
+
"; ".join(problems[:3]) + ("…" if len(problems) > 3 else ""),
|
|
97
|
+
remedy="re-run `veltro install` or fill VELTRO_IDENTITY_* in .env",
|
|
98
|
+
)
|
|
99
|
+
return CheckResult("identity_credentials", "pass", "suite credentials present")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Bootstrap .env from .env.example (Python port of scripts/init-env.sh)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import stat
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from veltro_cli.lib.errors import VeltroCliError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _hex_secret(nbytes: int = 32) -> str:
|
|
15
|
+
return secrets.token_hex(nbytes)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate_env_text(example_text: str) -> tuple[str, list[str]]:
|
|
19
|
+
"""Replace secret placeholders and keep identity DSNs consistent."""
|
|
20
|
+
generated: list[str] = []
|
|
21
|
+
generated_values: dict[str, str] = {}
|
|
22
|
+
out_lines: list[str] = []
|
|
23
|
+
for line in example_text.splitlines(keepends=True):
|
|
24
|
+
if "=" in line and not line.lstrip().startswith("#"):
|
|
25
|
+
key, _, value = line.rstrip("\n").partition("=")
|
|
26
|
+
key = key.strip()
|
|
27
|
+
if key and value.startswith("change-me"):
|
|
28
|
+
if key == "OPENSEARCH_ADMIN_PASSWORD":
|
|
29
|
+
# OpenSearch requires upper+lower+digit+special.
|
|
30
|
+
secret = _hex_secret(24) + "Aa1@"
|
|
31
|
+
else:
|
|
32
|
+
secret = _hex_secret(32)
|
|
33
|
+
out_lines.append(f"{key}={secret}\n")
|
|
34
|
+
generated.append(key)
|
|
35
|
+
generated_values[key] = secret
|
|
36
|
+
continue
|
|
37
|
+
out_lines.append(line if line.endswith("\n") else line + "\n")
|
|
38
|
+
|
|
39
|
+
text = "".join(out_lines)
|
|
40
|
+
dsn_passwords = {
|
|
41
|
+
"VELTRO_IDENTITY_MIGRATION_DATABASE_URL": "VELTRO_IDENTITY_OWNER_PASSWORD",
|
|
42
|
+
"VELTRO_IDENTITY_RUNTIME_DATABASE_URL": "VELTRO_IDENTITY_RUNTIME_PASSWORD",
|
|
43
|
+
"VELTRO_IDENTITY_IMPORTER_DATABASE_URL": "VELTRO_IDENTITY_IMPORTER_PASSWORD",
|
|
44
|
+
"VELTRO_IDENTITY_RECOVERY_DATABASE_URL": "VELTRO_IDENTITY_RECOVERY_PASSWORD",
|
|
45
|
+
}
|
|
46
|
+
lines: list[str] = []
|
|
47
|
+
for line in text.splitlines(keepends=True):
|
|
48
|
+
key, separator, value = line.partition("=")
|
|
49
|
+
password_key = dsn_passwords.get(key.strip()) if separator else None
|
|
50
|
+
password = generated_values.get(password_key or "")
|
|
51
|
+
if password and ":***@" in value:
|
|
52
|
+
line = f"{key}={value.replace(':***@', f':{password}@', 1)}"
|
|
53
|
+
lines.append(line)
|
|
54
|
+
return "".join(lines), generated
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_env(
|
|
58
|
+
root: Path,
|
|
59
|
+
*,
|
|
60
|
+
force: bool = False,
|
|
61
|
+
example_name: str = ".env.example",
|
|
62
|
+
env_name: str = ".env",
|
|
63
|
+
) -> list[str]:
|
|
64
|
+
example = root / example_name
|
|
65
|
+
env_path = root / env_name
|
|
66
|
+
if not example.is_file():
|
|
67
|
+
raise VeltroCliError(
|
|
68
|
+
f"{example} not found",
|
|
69
|
+
remedy="run this from a Veltro install directory (after `veltro install`) or pass --dir",
|
|
70
|
+
)
|
|
71
|
+
if env_path.is_file() and not force:
|
|
72
|
+
raise VeltroCliError(
|
|
73
|
+
f"{env_path} already exists — refusing to overwrite",
|
|
74
|
+
remedy="re-run with --force to regenerate secrets",
|
|
75
|
+
)
|
|
76
|
+
text, generated = generate_env_text(example.read_text(encoding="utf-8"))
|
|
77
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{env_path.name}.", dir=env_path.parent)
|
|
78
|
+
temp_path = Path(temp_name)
|
|
79
|
+
try:
|
|
80
|
+
os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
|
|
81
|
+
stream = os.fdopen(fd, "w", encoding="utf-8")
|
|
82
|
+
fd = -1
|
|
83
|
+
with stream:
|
|
84
|
+
stream.write(text)
|
|
85
|
+
stream.flush()
|
|
86
|
+
os.fsync(stream.fileno())
|
|
87
|
+
os.replace(temp_path, env_path)
|
|
88
|
+
except BaseException:
|
|
89
|
+
if fd >= 0:
|
|
90
|
+
os.close(fd)
|
|
91
|
+
temp_path.unlink(missing_ok=True)
|
|
92
|
+
raise
|
|
93
|
+
return generated
|
veltro_cli/lib/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Operator-facing CLI errors with remedy + support-bundle hint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VeltroCliError(Exception):
|
|
7
|
+
"""A named, operator-facing failure."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
remedy: str | None = None,
|
|
14
|
+
code: int = 1,
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.message = message
|
|
18
|
+
self.remedy = remedy
|
|
19
|
+
self.code = code
|
|
20
|
+
|
|
21
|
+
def format(self) -> str:
|
|
22
|
+
lines = [f"error: {self.message}"]
|
|
23
|
+
if self.remedy:
|
|
24
|
+
lines.append(f"remedy: {self.remedy}")
|
|
25
|
+
lines.append("hint: run `veltro support-bundle` and attach the archive when asking for help")
|
|
26
|
+
return "\n".join(lines)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_error(exc: BaseException, *, file=None) -> int:
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
out = file or sys.stderr
|
|
35
|
+
if isinstance(exc, VeltroCliError):
|
|
36
|
+
print(exc.format(), file=out)
|
|
37
|
+
return exc.code
|
|
38
|
+
print(f"error: {exc}", file=out)
|
|
39
|
+
print("hint: run `veltro support-bundle` and attach the archive when asking for help", file=out)
|
|
40
|
+
return 1
|
veltro_cli/lib/host.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Host preflight checks used by doctor and install."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Sequence
|
|
17
|
+
|
|
18
|
+
from veltro_cli.lib.compose import which_docker
|
|
19
|
+
|
|
20
|
+
# Minimum specs from the plan (README "Minimum specs").
|
|
21
|
+
MIN_SPECS = {"vcpu": 4, "ram_gb": 16}
|
|
22
|
+
OPENSEARCH_VM_MAX_MAP = 262144
|
|
23
|
+
MIN_DISK_GB = 20
|
|
24
|
+
COMPOSE_MIN = (2, 20)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CheckResult:
|
|
29
|
+
name: str
|
|
30
|
+
status: str # pass | warn | fail
|
|
31
|
+
detail: str
|
|
32
|
+
remedy: str = ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ok(self) -> bool:
|
|
36
|
+
return self.status != "fail"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run(cmd: Sequence[str]) -> tuple[int, str, str]:
|
|
40
|
+
try:
|
|
41
|
+
p = subprocess.run(list(cmd), capture_output=True, text=True, check=False)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return 127, "", f"{cmd[0]} not found"
|
|
44
|
+
return p.returncode, p.stdout or "", p.stderr or ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_python() -> CheckResult:
|
|
48
|
+
import sys
|
|
49
|
+
|
|
50
|
+
major, minor = sys.version_info[:2]
|
|
51
|
+
if (major, minor) >= (3, 10):
|
|
52
|
+
return CheckResult("python", "pass", f"{major}.{minor}")
|
|
53
|
+
return CheckResult(
|
|
54
|
+
"python",
|
|
55
|
+
"fail",
|
|
56
|
+
f"{major}.{minor}",
|
|
57
|
+
remedy="install Python ≥ 3.10",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_docker() -> CheckResult:
|
|
62
|
+
path = which_docker()
|
|
63
|
+
if not path:
|
|
64
|
+
return CheckResult("docker", "fail", "not on PATH", remedy="install Docker Engine or Desktop")
|
|
65
|
+
code, out, err = _run([path, "version", "--format", "{{.Server.Version}}"])
|
|
66
|
+
if code != 0:
|
|
67
|
+
return CheckResult(
|
|
68
|
+
"docker",
|
|
69
|
+
"fail",
|
|
70
|
+
(err or out or "daemon not reachable").strip(),
|
|
71
|
+
remedy="start the Docker daemon",
|
|
72
|
+
)
|
|
73
|
+
return CheckResult("docker", "pass", out.strip() or path)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_compose_version(text: str) -> tuple[int, int] | None:
|
|
77
|
+
# docker compose version outputs like "Docker Compose version v2.29.1"
|
|
78
|
+
match = re.search(r"v?(\d+)\.(\d+)", text)
|
|
79
|
+
if not match:
|
|
80
|
+
return None
|
|
81
|
+
return int(match.group(1)), int(match.group(2))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_compose() -> CheckResult:
|
|
85
|
+
path = which_docker()
|
|
86
|
+
if not path:
|
|
87
|
+
return CheckResult("compose", "fail", "docker missing", remedy="install Docker with Compose v2")
|
|
88
|
+
code, out, err = _run([path, "compose", "version"])
|
|
89
|
+
if code != 0:
|
|
90
|
+
return CheckResult(
|
|
91
|
+
"compose",
|
|
92
|
+
"fail",
|
|
93
|
+
(err or out or "compose plugin missing").strip(),
|
|
94
|
+
remedy="install Docker Compose ≥ 2.20 (compose plugin)",
|
|
95
|
+
)
|
|
96
|
+
version = parse_compose_version(out + err)
|
|
97
|
+
if version is None:
|
|
98
|
+
return CheckResult("compose", "warn", (out or err).strip() or "unknown version")
|
|
99
|
+
if version < COMPOSE_MIN:
|
|
100
|
+
return CheckResult(
|
|
101
|
+
"compose",
|
|
102
|
+
"fail",
|
|
103
|
+
f"{version[0]}.{version[1]}",
|
|
104
|
+
remedy="upgrade Docker Compose to ≥ 2.20 (required for `include:`)",
|
|
105
|
+
)
|
|
106
|
+
return CheckResult("compose", "pass", f"{version[0]}.{version[1]}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_disk(path: Path, *, min_gb: int = MIN_DISK_GB) -> CheckResult:
|
|
110
|
+
try:
|
|
111
|
+
usage = shutil.disk_usage(path if path.exists() else path.parent)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
return CheckResult("disk", "fail", str(exc), remedy="choose a writable --dir")
|
|
114
|
+
free_gb = usage.free / (1024**3)
|
|
115
|
+
if free_gb < min_gb:
|
|
116
|
+
return CheckResult(
|
|
117
|
+
"disk",
|
|
118
|
+
"fail",
|
|
119
|
+
f"{free_gb:.1f} GiB free",
|
|
120
|
+
remedy=f"free at least {min_gb} GiB on the install volume",
|
|
121
|
+
)
|
|
122
|
+
return CheckResult("disk", "pass", f"{free_gb:.1f} GiB free")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_vm_max_map_count() -> int | None:
|
|
126
|
+
proc = Path("/proc/sys/vm/max_map_count")
|
|
127
|
+
if proc.is_file():
|
|
128
|
+
try:
|
|
129
|
+
return int(proc.read_text(encoding="utf-8").strip())
|
|
130
|
+
except ValueError:
|
|
131
|
+
return None
|
|
132
|
+
# macOS / other — Docker VM handles this; treat as N/A.
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def check_vm_max_map_count(*, required: bool) -> CheckResult:
|
|
137
|
+
value = read_vm_max_map_count()
|
|
138
|
+
if value is None:
|
|
139
|
+
return CheckResult(
|
|
140
|
+
"vm.max_map_count",
|
|
141
|
+
"pass" if not required else "warn",
|
|
142
|
+
"not applicable on this host (Docker VM)",
|
|
143
|
+
)
|
|
144
|
+
if value < OPENSEARCH_VM_MAX_MAP:
|
|
145
|
+
return CheckResult(
|
|
146
|
+
"vm.max_map_count",
|
|
147
|
+
"fail" if required else "warn",
|
|
148
|
+
str(value),
|
|
149
|
+
remedy=f"sysctl -w vm.max_map_count={OPENSEARCH_VM_MAX_MAP}",
|
|
150
|
+
)
|
|
151
|
+
return CheckResult("vm.max_map_count", "pass", str(value))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_port_free(port: int, host: str = "127.0.0.1") -> CheckResult:
|
|
155
|
+
"""Best-effort free-port probe.
|
|
156
|
+
|
|
157
|
+
Connect-style first (is something listening?), then a bind attempt.
|
|
158
|
+
Permission errors on privileged ports are warnings on non-root hosts —
|
|
159
|
+
Docker can still publish them.
|
|
160
|
+
"""
|
|
161
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
162
|
+
try:
|
|
163
|
+
probe.settimeout(0.3)
|
|
164
|
+
try:
|
|
165
|
+
probe.connect((host, port))
|
|
166
|
+
except (ConnectionRefusedError, TimeoutError, OSError):
|
|
167
|
+
in_use = False
|
|
168
|
+
else:
|
|
169
|
+
in_use = True
|
|
170
|
+
finally:
|
|
171
|
+
probe.close()
|
|
172
|
+
if in_use:
|
|
173
|
+
return CheckResult(
|
|
174
|
+
f"port:{port}",
|
|
175
|
+
"fail",
|
|
176
|
+
f"{host}:{port} already accepting connections",
|
|
177
|
+
remedy=f"free TCP port {port} or change VELTRO_HTTP_PORT",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
181
|
+
try:
|
|
182
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
183
|
+
try:
|
|
184
|
+
sock.bind((host, port))
|
|
185
|
+
except PermissionError:
|
|
186
|
+
return CheckResult(
|
|
187
|
+
f"port:{port}",
|
|
188
|
+
"warn",
|
|
189
|
+
f"cannot bind {port} without privileges (will rely on Docker publish)",
|
|
190
|
+
)
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
return CheckResult(
|
|
193
|
+
f"port:{port}",
|
|
194
|
+
"fail",
|
|
195
|
+
str(exc),
|
|
196
|
+
remedy=f"free TCP port {port} or change VELTRO_HTTP_PORT",
|
|
197
|
+
)
|
|
198
|
+
return CheckResult(f"port:{port}", "pass", f"{port} free")
|
|
199
|
+
finally:
|
|
200
|
+
sock.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_versions_env(root: Path) -> CheckResult:
|
|
204
|
+
path = root / "versions.env"
|
|
205
|
+
if not path.is_file():
|
|
206
|
+
return CheckResult(
|
|
207
|
+
"versions.env",
|
|
208
|
+
"fail",
|
|
209
|
+
"missing",
|
|
210
|
+
remedy="restore versions.env from the release tarball / git checkout",
|
|
211
|
+
)
|
|
212
|
+
text = path.read_text(encoding="utf-8")
|
|
213
|
+
floating = []
|
|
214
|
+
for line in text.splitlines():
|
|
215
|
+
if "=" not in line or line.strip().startswith("#"):
|
|
216
|
+
continue
|
|
217
|
+
key, _, value = line.partition("=")
|
|
218
|
+
value = value.strip().strip('"').strip("'")
|
|
219
|
+
if value in {"latest", "dev"} or value.endswith(":latest"):
|
|
220
|
+
floating.append(f"{key.strip()}={value}")
|
|
221
|
+
if floating:
|
|
222
|
+
return CheckResult(
|
|
223
|
+
"versions.env",
|
|
224
|
+
"fail",
|
|
225
|
+
"floating tags: " + ", ".join(floating),
|
|
226
|
+
remedy="pin every image tag to an immutable release version",
|
|
227
|
+
)
|
|
228
|
+
return CheckResult("versions.env", "pass", "present, no floating tags")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def check_env_file(root: Path) -> CheckResult:
|
|
232
|
+
path = root / ".env"
|
|
233
|
+
if not path.is_file():
|
|
234
|
+
return CheckResult(
|
|
235
|
+
".env",
|
|
236
|
+
"fail",
|
|
237
|
+
"missing",
|
|
238
|
+
remedy="run `veltro install` or `python3 -m veltro_cli init-env`",
|
|
239
|
+
)
|
|
240
|
+
# Identity credentials are gated by validate_identity_credentials.
|
|
241
|
+
return CheckResult(".env", "pass", "present")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def check_public_url_dns(url: str) -> CheckResult:
|
|
245
|
+
from urllib.parse import urlparse
|
|
246
|
+
|
|
247
|
+
parsed = urlparse(url)
|
|
248
|
+
host = parsed.hostname
|
|
249
|
+
if not host:
|
|
250
|
+
return CheckResult("dns", "fail", f"unparseable URL {url!r}", remedy="set a valid --public-url")
|
|
251
|
+
if host in {"localhost", "127.0.0.1", "::1"}:
|
|
252
|
+
return CheckResult("dns", "pass", f"{host} (loopback)")
|
|
253
|
+
try:
|
|
254
|
+
socket.getaddrinfo(host, parsed.port or 443)
|
|
255
|
+
except socket.gaierror as exc:
|
|
256
|
+
return CheckResult(
|
|
257
|
+
"dns",
|
|
258
|
+
"warn",
|
|
259
|
+
f"{host}: {exc}",
|
|
260
|
+
remedy="create a DNS A/AAAA record for PUBLIC_BASE_URL before enabling --tls auto",
|
|
261
|
+
)
|
|
262
|
+
return CheckResult("dns", "pass", host)
|
|
263
|
+
|
|
264
|
+
def check_clock_skew(
|
|
265
|
+
*,
|
|
266
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
267
|
+
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
|
268
|
+
max_skew_seconds: int = 300,
|
|
269
|
+
) -> CheckResult:
|
|
270
|
+
request = urllib.request.Request("https://api.github.com/", method="HEAD")
|
|
271
|
+
try:
|
|
272
|
+
with opener(request, timeout=5) as response:
|
|
273
|
+
date_header = response.headers.get("Date")
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
return CheckResult(
|
|
276
|
+
"clock_skew",
|
|
277
|
+
"warn",
|
|
278
|
+
f"could not compare trusted time: {exc}",
|
|
279
|
+
remedy="verify NTP/time synchronization before enabling SSO",
|
|
280
|
+
)
|
|
281
|
+
if not date_header:
|
|
282
|
+
return CheckResult("clock_skew", "warn", "trusted response had no Date header")
|
|
283
|
+
try:
|
|
284
|
+
remote = parsedate_to_datetime(date_header).astimezone(timezone.utc)
|
|
285
|
+
except (TypeError, ValueError) as exc:
|
|
286
|
+
return CheckResult("clock_skew", "warn", f"invalid trusted Date header: {exc}")
|
|
287
|
+
skew = abs((now().astimezone(timezone.utc) - remote).total_seconds())
|
|
288
|
+
if skew > max_skew_seconds:
|
|
289
|
+
return CheckResult(
|
|
290
|
+
"clock_skew",
|
|
291
|
+
"fail",
|
|
292
|
+
f"{skew:.0f}s (maximum {max_skew_seconds}s)",
|
|
293
|
+
remedy="enable NTP/chrony and synchronize the host clock",
|
|
294
|
+
)
|
|
295
|
+
return CheckResult("clock_skew", "pass", f"{skew:.0f}s")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cpu_count() -> int:
|
|
299
|
+
return os.cpu_count() or 1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def ram_gb() -> float | None:
|
|
303
|
+
# Linux
|
|
304
|
+
meminfo = Path("/proc/meminfo")
|
|
305
|
+
if meminfo.is_file():
|
|
306
|
+
for line in meminfo.read_text(encoding="utf-8").splitlines():
|
|
307
|
+
if line.startswith("MemTotal:"):
|
|
308
|
+
kb = int(line.split()[1])
|
|
309
|
+
return kb / (1024 * 1024)
|
|
310
|
+
# macOS
|
|
311
|
+
code, out, _ = _run(["sysctl", "-n", "hw.memsize"])
|
|
312
|
+
if code == 0 and out.strip().isdigit():
|
|
313
|
+
return int(out.strip()) / (1024**3)
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def check_host_minimums() -> list[CheckResult]:
|
|
318
|
+
spec = MIN_SPECS
|
|
319
|
+
results: list[CheckResult] = []
|
|
320
|
+
cpus = cpu_count()
|
|
321
|
+
if cpus < spec["vcpu"]:
|
|
322
|
+
results.append(
|
|
323
|
+
CheckResult(
|
|
324
|
+
"cpu",
|
|
325
|
+
"warn",
|
|
326
|
+
f"{cpus} vCPU (recommended ≥ {spec['vcpu']})",
|
|
327
|
+
remedy="use a larger host for production",
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
results.append(CheckResult("cpu", "pass", f"{cpus} vCPU"))
|
|
332
|
+
ram = ram_gb()
|
|
333
|
+
if ram is None:
|
|
334
|
+
results.append(CheckResult("ram", "warn", "unknown"))
|
|
335
|
+
elif ram < spec["ram_gb"]:
|
|
336
|
+
results.append(
|
|
337
|
+
CheckResult(
|
|
338
|
+
"ram",
|
|
339
|
+
"warn",
|
|
340
|
+
f"{ram:.1f} GiB (recommended ≥ {spec['ram_gb']})",
|
|
341
|
+
remedy="use a larger host for production",
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
results.append(CheckResult("ram", "pass", f"{ram:.1f} GiB"))
|
|
346
|
+
return results
|