467 lines
17 KiB
Python
467 lines
17 KiB
Python
"""`packetstation doctor`: check config, VISUAL, M-Files, database, volumes, LDAP, SMTP and tzdata."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import secrets
|
||
|
|
import shutil
|
||
|
|
import smtplib
|
||
|
|
import ssl
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import uuid
|
||
|
|
from collections.abc import Callable
|
||
|
|
from dataclasses import asdict, dataclass
|
||
|
|
from email.message import EmailMessage
|
||
|
|
from typing import Any
|
||
|
|
from urllib.parse import urlparse
|
||
|
|
|
||
|
|
from pydantic import ValidationError
|
||
|
|
|
||
|
|
from . import __version__
|
||
|
|
from .settings import SecretFileError, Settings
|
||
|
|
|
||
|
|
PASS, WARN, FAIL, SKIP = "PASS", "WARN", "FAIL", "SKIP"
|
||
|
|
SKIPPABLE = ("visual", "mfiles", "ldap", "smtp", "db", "volume")
|
||
|
|
FREE_WARN_BYTES = 2 * 1024**3
|
||
|
|
EXPECTED_PS_GROUPS = 50
|
||
|
|
IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||
|
|
LDAP_ATTRS = ("memberOf", "employeeID", "userAccountControl")
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Check:
|
||
|
|
name: str
|
||
|
|
status: str
|
||
|
|
detail: str
|
||
|
|
hint: str = ""
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- config
|
||
|
|
|
||
|
|
|
||
|
|
def check_config(load: Callable[[], Settings] = Settings) -> tuple[list[Check], Settings | None]:
|
||
|
|
try:
|
||
|
|
s = load()
|
||
|
|
except ValidationError as e:
|
||
|
|
bad = "; ".join(
|
||
|
|
f"{'.'.join(str(x) for x in err['loc']).upper() or 'settings'}: {err['msg']}" for err in e.errors()
|
||
|
|
)
|
||
|
|
return [Check("config", FAIL, f"settings invalid: {bad}", "fix the environment (see .env.example)")], None
|
||
|
|
out = [
|
||
|
|
Check("config", PASS, f"STACK={s.stack} SITE_ID={s.site_id} build {s.ps_build}, packetstation {__version__}")
|
||
|
|
]
|
||
|
|
from . import guard
|
||
|
|
|
||
|
|
try:
|
||
|
|
g = guard.check_stack(s)
|
||
|
|
except guard.StackGuardError as e:
|
||
|
|
out.append(Check("config: stack guard", FAIL, str(e), "VISUAL_* must point at DEV unless STACK=prod"))
|
||
|
|
except Exception as e:
|
||
|
|
out.append(Check("config: stack guard", FAIL, f"can't read VisualKit settings: {e}"))
|
||
|
|
else:
|
||
|
|
out.append(Check("config: stack guard", PASS, f"VISUAL target {g.target}"))
|
||
|
|
for w in g.warnings:
|
||
|
|
out.append(Check("config: warning", WARN, w))
|
||
|
|
return out, s
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- visual / mfiles
|
||
|
|
|
||
|
|
|
||
|
|
def _lib_checks(prefix: str, items: list[Any]) -> list[Check]:
|
||
|
|
return [
|
||
|
|
Check(f"{prefix}: {c.name}", str(c.status), str(c.detail), str(getattr(c, "hint", "") or "")) for c in items
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
_LINE = re.compile(r"^\[(PASS|WARN|FAIL)\]\s+(.+?)\s{2,}(.*)$")
|
||
|
|
|
||
|
|
|
||
|
|
def parse_report(prefix: str, text: str) -> list[Check]:
|
||
|
|
"""Parse `[STATUS] name detail` lines (and `-> hint` lines) from a library's doctor output."""
|
||
|
|
out: list[Check] = []
|
||
|
|
for line in text.splitlines():
|
||
|
|
m = _LINE.match(line.rstrip())
|
||
|
|
if m:
|
||
|
|
out.append(Check(f"{prefix}: {m.group(2).strip()}", m.group(1), m.group(3).strip()))
|
||
|
|
elif line.strip().startswith("->") and out:
|
||
|
|
out[-1].hint = line.strip()[2:].strip()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _subprocess_doctor(prefix: str, argv: list[str]) -> list[Check]:
|
||
|
|
exe = shutil.which(argv[0])
|
||
|
|
if exe is None:
|
||
|
|
return [Check(prefix, FAIL, f"{argv[0]} is not installed")]
|
||
|
|
try:
|
||
|
|
r = subprocess.run([exe, *argv[1:]], capture_output=True, text=True, timeout=180, check=False)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
return [Check(prefix, FAIL, f"{' '.join(argv)} timed out")]
|
||
|
|
checks = parse_report(prefix, r.stdout)
|
||
|
|
if not checks:
|
||
|
|
return [
|
||
|
|
Check(
|
||
|
|
prefix, FAIL, f"{' '.join(argv)} exited {r.returncode} with no checks", (r.stderr or "").strip()[-300:]
|
||
|
|
)
|
||
|
|
]
|
||
|
|
return checks
|
||
|
|
|
||
|
|
|
||
|
|
def check_visual(s: Settings) -> list[Check]:
|
||
|
|
from . import guard
|
||
|
|
|
||
|
|
try:
|
||
|
|
vs = guard.load_visual_settings()
|
||
|
|
except Exception as e:
|
||
|
|
return [Check("visual", FAIL, f"VISUAL_* settings invalid: {e}")]
|
||
|
|
if vs.is_production and s.stack != "prod":
|
||
|
|
return [
|
||
|
|
Check(
|
||
|
|
"visual",
|
||
|
|
FAIL,
|
||
|
|
f"refusing PRODUCTION target {vs.server} / {vs.database} on STACK={s.stack}",
|
||
|
|
"point VISUAL_* at DEV",
|
||
|
|
)
|
||
|
|
]
|
||
|
|
allow = s.stack == "prod"
|
||
|
|
try:
|
||
|
|
from visualkit import doctor as vdoctor
|
||
|
|
except Exception:
|
||
|
|
argv = ["visualkit", "doctor"] + (["--allow-production"] if allow else [])
|
||
|
|
return _subprocess_doctor("visual", argv)
|
||
|
|
try:
|
||
|
|
return _lib_checks("visual", vdoctor.run(allow_production=allow, prompt_password=None))
|
||
|
|
except Exception as e:
|
||
|
|
return [Check("visual", FAIL, f"visualkit doctor raised {type(e).__name__}: {e}")]
|
||
|
|
|
||
|
|
|
||
|
|
def check_mfiles(s: Settings) -> list[Check]:
|
||
|
|
try:
|
||
|
|
from mfileskit import doctor as mdoctor
|
||
|
|
except Exception:
|
||
|
|
return _subprocess_doctor("mfiles", ["mfileskit", "doctor"])
|
||
|
|
try:
|
||
|
|
return _lib_checks("mfiles", mdoctor.run(prompt_password=None))
|
||
|
|
except Exception as e:
|
||
|
|
return [Check("mfiles", FAIL, f"mfileskit doctor raised {type(e).__name__}: {e}")]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- database
|
||
|
|
|
||
|
|
|
||
|
|
def _first_line(e: Exception) -> str:
|
||
|
|
text = str(e).strip()
|
||
|
|
return text.splitlines()[0] if text else type(e).__name__
|
||
|
|
|
||
|
|
|
||
|
|
def check_db(s: Settings) -> list[Check]:
|
||
|
|
import psycopg
|
||
|
|
|
||
|
|
from . import db, migrate
|
||
|
|
|
||
|
|
out: list[Check] = []
|
||
|
|
where = f"{s.pg_app_user}@{s.pg_host}:{s.pg_port}/{s.pg_database}"
|
||
|
|
try:
|
||
|
|
conn = db.connect_as("app", s, autocommit=True)
|
||
|
|
except SecretFileError as e:
|
||
|
|
return [Check("db: connect", FAIL, str(e), "mount the postgres_app_password secret")]
|
||
|
|
except psycopg.Error as e:
|
||
|
|
return [
|
||
|
|
Check(
|
||
|
|
"db: connect",
|
||
|
|
FAIL,
|
||
|
|
f"{where}: {type(e).__name__}: {_first_line(e)}",
|
||
|
|
"is postgres up, and do the roles exist (initdb or migrate --bootstrap-roles)?",
|
||
|
|
)
|
||
|
|
]
|
||
|
|
with conn:
|
||
|
|
ver = conn.execute("SHOW server_version").fetchone()
|
||
|
|
out.append(Check("db: connect", PASS, f"{where}, PostgreSQL {ver[0] if ver else '?'}"))
|
||
|
|
try:
|
||
|
|
ok, detail = migrate.status(conn, db.MIGRATIONS_DIR)
|
||
|
|
except psycopg.Error as e:
|
||
|
|
ok, detail = False, f"can't read schema_migrations: {type(e).__name__}"
|
||
|
|
out.append(Check("db: migrations", PASS if ok else FAIL, detail, "" if ok else "run: packetstation migrate"))
|
||
|
|
|
||
|
|
try:
|
||
|
|
with conn.transaction(force_rollback=True):
|
||
|
|
conn.execute("UPDATE audit_events SET action = action WHERE id = -1")
|
||
|
|
out.append(
|
||
|
|
Check(
|
||
|
|
"db: audit append-only",
|
||
|
|
FAIL,
|
||
|
|
f"{s.pg_app_user} may UPDATE audit_events",
|
||
|
|
"re-apply the 0001 grants (REVOKE UPDATE, DELETE ON audit_events FROM ps_app)",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
except psycopg.errors.InsufficientPrivilege:
|
||
|
|
out.append(Check("db: audit append-only", PASS, "UPDATE on audit_events is denied for the app role"))
|
||
|
|
except psycopg.Error as e:
|
||
|
|
out.append(Check("db: audit append-only", FAIL, f"unexpected {type(e).__name__}: {_first_line(e)}"))
|
||
|
|
|
||
|
|
channel = f"ps_doctor_{secrets.token_hex(4)}"
|
||
|
|
payload = str(uuid.uuid4())
|
||
|
|
try:
|
||
|
|
conn.execute(f'LISTEN "{channel}"')
|
||
|
|
conn.execute("SELECT ps_notify(%s, %s)", (channel, payload))
|
||
|
|
got = [n.payload for n in conn.notifies(timeout=5, stop_after=1)]
|
||
|
|
conn.execute(f'UNLISTEN "{channel}"')
|
||
|
|
if payload in got:
|
||
|
|
out.append(Check("db: listen/notify", PASS, "round trip through ps_notify"))
|
||
|
|
else:
|
||
|
|
out.append(Check("db: listen/notify", FAIL, "no notification within 5 s"))
|
||
|
|
except psycopg.Error as e:
|
||
|
|
out.append(Check("db: listen/notify", FAIL, f"{type(e).__name__}: {_first_line(e)}"))
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- volumes
|
||
|
|
|
||
|
|
|
||
|
|
def check_volume(s: Settings) -> list[Check]:
|
||
|
|
out: list[Check] = []
|
||
|
|
a = s.assets_dir
|
||
|
|
if not a.is_dir():
|
||
|
|
out.append(Check("volume: assets", FAIL, f"ASSETS_DIR {a} does not exist", "mount the assets volume"))
|
||
|
|
else:
|
||
|
|
probe = a / f".doctor-{secrets.token_hex(4)}"
|
||
|
|
try:
|
||
|
|
with open(probe, "wb") as f:
|
||
|
|
f.write(b"ok")
|
||
|
|
f.flush()
|
||
|
|
os.fsync(f.fileno())
|
||
|
|
probe.unlink()
|
||
|
|
except OSError as e:
|
||
|
|
out.append(
|
||
|
|
Check(
|
||
|
|
"volume: assets",
|
||
|
|
FAIL,
|
||
|
|
f"ASSETS_DIR {a} not writable: {e.strerror}",
|
||
|
|
"the volume must be writable by uid 10001",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
free = shutil.disk_usage(a).free
|
||
|
|
gb = free / 1024**3
|
||
|
|
if free < FREE_WARN_BYTES:
|
||
|
|
out.append(Check("volume: assets", WARN, f"{a} writable, only {gb:.1f} GB free", "free some space"))
|
||
|
|
else:
|
||
|
|
out.append(Check("volume: assets", PASS, f"{a} writable, {gb:.1f} GB free"))
|
||
|
|
b = s.backup_dir
|
||
|
|
if not b.exists():
|
||
|
|
out.append(Check("volume: backups", SKIP, f"BACKUP_DIR {b} not mounted"))
|
||
|
|
else:
|
||
|
|
try:
|
||
|
|
n = len(os.listdir(b))
|
||
|
|
out.append(Check("volume: backups", PASS, f"{b} readable, {n} entries"))
|
||
|
|
except OSError as e:
|
||
|
|
out.append(Check("volume: backups", FAIL, f"{b} not readable: {e.strerror}"))
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- ldap
|
||
|
|
|
||
|
|
|
||
|
|
def _ldap_one(s: Settings, url: str, password: str) -> Check:
|
||
|
|
import ldap3
|
||
|
|
from ldap3.core.exceptions import LDAPException
|
||
|
|
from ldap3.utils.conv import escape_filter_chars
|
||
|
|
|
||
|
|
name = f"ldap: {urlparse(url).hostname or url}"
|
||
|
|
ldaps = url.lower().startswith("ldaps://")
|
||
|
|
if not (ldaps or s.ldap_starttls) and not s.ldap_allow_insecure:
|
||
|
|
return Check(
|
||
|
|
name,
|
||
|
|
FAIL,
|
||
|
|
f"{url} is plain LDAP without STARTTLS",
|
||
|
|
"use ldaps:// or LDAP_STARTTLS=true (LDAP_ALLOW_INSECURE only off prod)",
|
||
|
|
)
|
||
|
|
tls = ldap3.Tls(
|
||
|
|
validate=ssl.CERT_REQUIRED,
|
||
|
|
version=ssl.PROTOCOL_TLS_CLIENT,
|
||
|
|
ca_certs_file=str(s.ldap_ca_bundle) if s.ldap_ca_bundle else None,
|
||
|
|
)
|
||
|
|
server = ldap3.Server(url, use_ssl=ldaps, tls=tls, get_info=ldap3.SCHEMA, connect_timeout=10)
|
||
|
|
try:
|
||
|
|
conn = ldap3.Connection(
|
||
|
|
server, user=s.ldap_bind_dn, password=password, receive_timeout=15, raise_exceptions=False
|
||
|
|
)
|
||
|
|
conn.open()
|
||
|
|
if s.ldap_starttls and not ldaps and not conn.start_tls():
|
||
|
|
return Check(name, FAIL, f"STARTTLS failed: {conn.result.get('description')}")
|
||
|
|
if not conn.bind():
|
||
|
|
return Check(
|
||
|
|
name,
|
||
|
|
FAIL,
|
||
|
|
f"bind as {s.ldap_bind_dn} failed: {conn.result.get('description')}",
|
||
|
|
"check LDAP_BIND_DN and the ldap_bind_password secret",
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
schema = server.schema
|
||
|
|
missing = [a for a in LDAP_ATTRS if not schema or a not in schema.attribute_types]
|
||
|
|
if missing:
|
||
|
|
return Check(name, FAIL, f"schema lacks {', '.join(missing)}")
|
||
|
|
conn.search(s.ldap_bind_dn, "(objectClass=*)", ldap3.BASE, attributes=list(LDAP_ATTRS))
|
||
|
|
if not conn.entries:
|
||
|
|
return Check(name, FAIL, "can't read the service account's own entry")
|
||
|
|
parts = ["bind ok, service entry readable"]
|
||
|
|
status = PASS
|
||
|
|
if s.ldap_group_base_dn:
|
||
|
|
groups = conn.extend.standard.paged_search(
|
||
|
|
s.ldap_group_base_dn,
|
||
|
|
"(&(objectClass=group)(cn=PS-*))",
|
||
|
|
ldap3.SUBTREE,
|
||
|
|
attributes=["cn"],
|
||
|
|
paged_size=200,
|
||
|
|
generator=False,
|
||
|
|
)
|
||
|
|
count = sum(1 for g in groups if g.get("type") == "searchResEntry")
|
||
|
|
if count == EXPECTED_PS_GROUPS:
|
||
|
|
parts.append(f"{count} PS-* groups")
|
||
|
|
else:
|
||
|
|
status = WARN
|
||
|
|
parts.append(f"{count} PS-* groups (expected {EXPECTED_PS_GROUPS})")
|
||
|
|
base = s.ldap_group_base_dn
|
||
|
|
else:
|
||
|
|
status = WARN
|
||
|
|
parts.append("LDAP_GROUP_BASE_DN not set")
|
||
|
|
base = s.ldap_base_dn
|
||
|
|
if base:
|
||
|
|
ok = conn.search(
|
||
|
|
base,
|
||
|
|
f"(member:{IN_CHAIN}:={escape_filter_chars(s.ldap_bind_dn)})",
|
||
|
|
ldap3.SUBTREE,
|
||
|
|
attributes=["cn"],
|
||
|
|
size_limit=500,
|
||
|
|
)
|
||
|
|
desc = conn.result.get("description")
|
||
|
|
if ok or desc == "success":
|
||
|
|
parts.append(f"IN_CHAIN query ok ({len(conn.entries)} groups hold the service account)")
|
||
|
|
else:
|
||
|
|
status = FAIL
|
||
|
|
parts.append(f"IN_CHAIN query failed: {desc}")
|
||
|
|
return Check(name, status, "; ".join(parts))
|
||
|
|
finally:
|
||
|
|
conn.unbind()
|
||
|
|
except LDAPException as e:
|
||
|
|
return Check(name, FAIL, f"{type(e).__name__}: {e}", "check LDAP_CA_BUNDLE and that the port is reachable")
|
||
|
|
|
||
|
|
|
||
|
|
def check_ldap(s: Settings) -> list[Check]:
|
||
|
|
urls = s.ldap_urls
|
||
|
|
if not urls or not s.ldap_bind_dn:
|
||
|
|
return [Check("ldap", FAIL, "LDAP_URL or LDAP_BIND_DN not set", "set them, or --skip ldap")]
|
||
|
|
try:
|
||
|
|
password = s.ldap_password()
|
||
|
|
except SecretFileError as e:
|
||
|
|
return [Check("ldap", FAIL, str(e), "mount the ldap_bind_password secret")]
|
||
|
|
return [_ldap_one(s, url, password) for url in urls]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- smtp
|
||
|
|
|
||
|
|
|
||
|
|
def check_smtp(s: Settings, send_test: str | None = None) -> list[Check]:
|
||
|
|
name = f"smtp: {s.smtp_host}:{s.smtp_port}"
|
||
|
|
try:
|
||
|
|
with smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=15) as smtp:
|
||
|
|
smtp.ehlo()
|
||
|
|
if s.smtp_starttls:
|
||
|
|
ctx = ssl.create_default_context(cafile=str(s.smtp_ca_bundle) if s.smtp_ca_bundle else None)
|
||
|
|
smtp.starttls(context=ctx)
|
||
|
|
smtp.ehlo()
|
||
|
|
out = [Check(name, PASS, "EHLO and STARTTLS with a verified certificate")]
|
||
|
|
else:
|
||
|
|
out = [Check(name, WARN, "EHLO ok; STARTTLS is off (SMTP_STARTTLS=false)")]
|
||
|
|
if send_test:
|
||
|
|
msg = EmailMessage()
|
||
|
|
msg["From"] = s.smtp_from
|
||
|
|
msg["To"] = send_test
|
||
|
|
msg["Subject"] = f"Packet Station doctor test ({s.stack})"
|
||
|
|
msg.set_content(f"Test message from packetstation doctor, build {s.ps_build}.")
|
||
|
|
smtp.send_message(msg)
|
||
|
|
out.append(Check("smtp: send test", PASS, f"sent to {send_test}"))
|
||
|
|
return out
|
||
|
|
except ssl.SSLError as e:
|
||
|
|
return [Check(name, FAIL, f"TLS: {e}", "set SMTP_CA_BUNDLE to the CA that signed the relay certificate")]
|
||
|
|
except (OSError, smtplib.SMTPException) as e:
|
||
|
|
return [Check(name, FAIL, f"{type(e).__name__}: {e}")]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- tz
|
||
|
|
|
||
|
|
|
||
|
|
def check_tz(tz: str) -> Check:
|
||
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||
|
|
|
||
|
|
try:
|
||
|
|
ZoneInfo(tz)
|
||
|
|
except (ZoneInfoNotFoundError, ValueError) as e:
|
||
|
|
return Check("tz", FAIL, f"SITE_TZ {tz} does not load: {e}", "install tzdata")
|
||
|
|
return Check("tz", PASS, f"{tz} loads")
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- runner
|
||
|
|
|
||
|
|
|
||
|
|
def run(
|
||
|
|
skip: set[str] | None = None, send_test: str | None = None, load: Callable[[], Settings] = Settings
|
||
|
|
) -> list[Check]:
|
||
|
|
skip = skip or set()
|
||
|
|
checks, s = check_config(load)
|
||
|
|
steps: list[tuple[str, Callable[[Settings], list[Check]]]] = [
|
||
|
|
("visual", check_visual),
|
||
|
|
("mfiles", check_mfiles),
|
||
|
|
("db", check_db),
|
||
|
|
("volume", check_volume),
|
||
|
|
("ldap", check_ldap),
|
||
|
|
("smtp", lambda st: check_smtp(st, send_test)),
|
||
|
|
]
|
||
|
|
for key, fn in steps:
|
||
|
|
if key in skip:
|
||
|
|
checks.append(Check(key, SKIP, "skipped (--skip)"))
|
||
|
|
elif s is None:
|
||
|
|
checks.append(Check(key, SKIP, "settings did not load"))
|
||
|
|
else:
|
||
|
|
try:
|
||
|
|
checks.extend(fn(s))
|
||
|
|
except Exception as e:
|
||
|
|
checks.append(Check(key, FAIL, f"{type(e).__name__}: {e}"))
|
||
|
|
checks.append(check_tz(s.site_tz if s else "America/Denver"))
|
||
|
|
return checks
|
||
|
|
|
||
|
|
|
||
|
|
def failed(checks: list[Check]) -> bool:
|
||
|
|
return any(c.status == FAIL for c in checks)
|
||
|
|
|
||
|
|
|
||
|
|
def as_json(checks: list[Check]) -> list[dict[str, str]]:
|
||
|
|
return [asdict(c) for c in checks]
|
||
|
|
|
||
|
|
|
||
|
|
def report(checks: list[Check]) -> str:
|
||
|
|
width = max((len(c.name) for c in checks), default=0)
|
||
|
|
lines = []
|
||
|
|
for c in checks:
|
||
|
|
lines.append(f"[{c.status}] {c.name:<{width}} {c.detail}")
|
||
|
|
if c.hint and c.status not in (PASS, SKIP):
|
||
|
|
lines.append(f" {'':<{width}} -> {c.hint}")
|
||
|
|
fails = sum(c.status == FAIL for c in checks)
|
||
|
|
warns = sum(c.status == WARN for c in checks)
|
||
|
|
lines.append("")
|
||
|
|
lines.append("All checks passed" if not fails and not warns else f"{fails} failed, {warns} warnings")
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def parse_skip(text: str | None) -> set[str]:
|
||
|
|
if not text:
|
||
|
|
return set()
|
||
|
|
items = {x.strip().lower() for x in text.split(",") if x.strip()}
|
||
|
|
bad = items - set(SKIPPABLE)
|
||
|
|
if bad:
|
||
|
|
print(f"unknown --skip value(s): {', '.join(sorted(bad))}; choose from {', '.join(SKIPPABLE)}", file=sys.stderr)
|
||
|
|
raise SystemExit(2)
|
||
|
|
return items
|