02250473ad
Fix all failing CI quality gates (lint, unit_tests, format) without suppressing any quality enforcement. Root causes and fixes: 1. Format: features/steps/plan_namespaced_name_tdd_steps.py had trailing whitespace; fixed by running ruff format. 2. Unit tests - A2A JSON-RPC 2.0 migration (commit9c6d6915) renamed A2aRequest fields (operation→method, request_id→id, a2a_version→jsonrpc) and A2aResponse fields (status+data→result, request_id→id) but did not update all step files and feature files: - a2a_jsonrpc_wire_format_steps.py: added use_step_matcher('re') and reset to 'parse' at end to prevent parallel test interference - a2a_facade_wiring_steps.py: updated operation= to method=, .status/.data to .result - a2a_facade_steps.py: updated request_id→id, a2a_version→jsonrpc, A2aResponse(request_id=..., status=...) to new API - m6_facade_steps.py: updated all old API usage - devcontainer_cleanup_steps.py: updated A2aRequest(operation=...) - plan_prompt_command_steps.py: updated A2aRequest(operation=...) - wf03_plan_prompt_confidence_steps.py: updated A2aRequest(operation=...) - consolidated_misc.feature: updated old A2aRequest/A2aResponse scenarios 3. Unit tests - Session CLI output changed (commit0d5d9cf0and others): - 'Session Created' → 'Session created' (lowercase) - 'Session Details' → 'Session Summary' - 'Sessions (N total)' → 'Sessions' - session list JSON: top-level 'total' → nested 'summary.total' - Fixed in: session_cli.feature, session_cli_coverage_boost.feature, session_cli_uncovered_branches.feature, session_list_error.feature, tdd_session_create_persist_steps.py 4. Unit tests - Plan list output changed (commit1a07a891): - 'V3 Lifecycle Plans' → 'Plans' - 'Lifecycle Plans' → 'Plans' - Name column removed (restored in source) - Invariants column removed (restored in source) - Project truncation removed (restored in source) - Fixed in: plan_cli_cancel_revert_coverage.feature, plan_lifecycle_cli_coverage.feature, plan_cli_coverage_boost_steps.py, plan.py (source code restored) 5. Unit tests - Plan apply command now requires ULID (commit300a5d6d): - plan_cli_coverage_r3.feature: updated 'PLAN-001' to valid ULID - plan_cli_coverage_r3_steps.py: added --yes flag, added new step for no-eligible-plans path 6. Unit tests - Various source code bugs: - ThoughtBlock: converted from @dataclass to Pydantic BaseModel (architecture test requires all dataclasses to use Pydantic) - session.py: added DatabaseError handling to export, import, tell commands - database.py: fixed rollback_to() to reuse checkpoint connection for writes - database.py: added _get_checkpoint_conn() helper - check-tls-cert.py: fixed SSLCertVerificationError.reason AttributeError 7. Unit tests - Test step bugs: - error_recovery_coverage_boost_steps.py: fixed invalid ULID _PLAN_ID - session_service_coverage_steps.py: fixed 'sha256:' prefix bug in checksum - database_models_new_coverage_steps.py: added 'name' field to session mock - async_audit_recording_steps.py: fixed Settings(audit_async=False) via env var - coverage_threshold_config_steps.py: added --coverage-min pattern support - m5_acms_smoke_steps.py: updated usage hint text - actor_cli_yaml_steps.py: updated 'Removed actor' → 'Actor removed' - aimodelscredentials_steps.py: set context.imported_class in import step - domain_base_model.feature: added missing 'When I examine model_config' step - tui_first_run_steps.py: fixed module reload to restore cleveragents.tui.* modules after test (prevented patch interference in subsequent tests) - tui_first_run_steps.py: added set_search('') step for empty string - resource_handler_base_coverage_r3_steps.py: use _MinimalHandler instead of DatabaseResourceHandler for NotImplementedError tests - resource_handler_crud.feature: updated to test new DatabaseHandler behavior - resource_handler_sandbox.feature: updated to test new DatabaseHandler behavior - tdd_json_decode_crash_persistence.feature: fixed @tdd_bug → @tdd_issue tags 8. Parallel test interference: - All step files using use_step_matcher('re') now reset to 'parse' at end to prevent global matcher state leaking to subsequent step files
310 lines
10 KiB
Python
310 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""TLS certificate health-check script for CleverAgents infrastructure.
|
|
|
|
Verifies that a given hostname's TLS certificate:
|
|
- Is reachable via HTTPS
|
|
- Presents a valid certificate trusted by the system CA bundle
|
|
- Includes the target hostname as a Subject Alternative Name (SAN)
|
|
- Has not expired and will not expire within the warning threshold
|
|
|
|
Usage:
|
|
python scripts/check-tls-cert.py git.dev.cleveragents.com
|
|
python scripts/check-tls-cert.py git.dev.cleveragents.com --port 443
|
|
python scripts/check-tls-cert.py git.dev.cleveragents.com --warn-days 30
|
|
python scripts/check-tls-cert.py git.dev.cleveragents.com git.cleveragents.com
|
|
|
|
Exit codes:
|
|
0 All checks passed
|
|
1 One or more checks failed (certificate error, expiry, SAN mismatch)
|
|
2 Usage error (bad arguments)
|
|
|
|
This script is used by the ops team to diagnose TLS issues and by CI to
|
|
monitor certificate health. See docs/development/ops-runbook.md for the
|
|
full certificate renewal procedure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass, field
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class CertCheckResult:
|
|
"""Result of a single TLS certificate check for one hostname."""
|
|
|
|
hostname: str
|
|
port: int
|
|
ok: bool = True
|
|
errors: list[str] = field(default_factory=list)
|
|
warnings: list[str] = field(default_factory=list)
|
|
subject_cn: str = ""
|
|
sans: list[str] = field(default_factory=list)
|
|
not_after: datetime.datetime | None = None
|
|
days_remaining: int | None = None
|
|
|
|
def add_error(self, msg: str) -> None:
|
|
"""Record a fatal error and mark the result as failed."""
|
|
self.errors.append(msg)
|
|
self.ok = False
|
|
|
|
def add_warning(self, msg: str) -> None:
|
|
"""Record a non-fatal warning (does not affect ok status)."""
|
|
self.warnings.append(msg)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Certificate inspection helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _parse_not_after(cert: dict) -> datetime.datetime | None:
|
|
"""Parse the ``notAfter`` field from a DER-decoded certificate dict.
|
|
|
|
Returns a timezone-aware UTC datetime, or ``None`` if the field is
|
|
absent or cannot be parsed.
|
|
"""
|
|
not_after_str: str | None = cert.get("notAfter")
|
|
if not not_after_str:
|
|
return None
|
|
try:
|
|
# ssl module returns strings like "Apr 2 23:59:59 2026 GMT"
|
|
dt = datetime.datetime.strptime(not_after_str, "%b %d %H:%M:%S %Y %Z")
|
|
return dt.replace(tzinfo=datetime.UTC)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _extract_sans(cert: dict) -> list[str]:
|
|
"""Extract Subject Alternative Names from a DER-decoded certificate dict.
|
|
|
|
Returns a list of SAN values (e.g. ``["git.dev.cleveragents.com",
|
|
"git.cleveragents.com"]``). DNS SANs are returned without the
|
|
``DNS:`` prefix.
|
|
"""
|
|
sans: list[str] = []
|
|
for san_type, san_value in cert.get("subjectAltName", ()):
|
|
if san_type == "DNS":
|
|
sans.append(san_value)
|
|
return sans
|
|
|
|
|
|
def _extract_cn(cert: dict) -> str:
|
|
"""Extract the Common Name (CN) from the certificate subject."""
|
|
for rdn in cert.get("subject", ()):
|
|
for attr_type, attr_value in rdn:
|
|
if attr_type == "commonName":
|
|
return attr_value
|
|
return ""
|
|
|
|
|
|
def _hostname_matches_san(hostname: str, sans: list[str]) -> bool:
|
|
"""Return True if *hostname* matches any SAN entry.
|
|
|
|
Supports exact matches and single-level wildcard patterns
|
|
(e.g. ``*.cleveragents.com`` matches ``git.cleveragents.com`` but
|
|
not ``a.b.cleveragents.com``).
|
|
"""
|
|
hostname_lower = hostname.lower()
|
|
for san in sans:
|
|
san_lower = san.lower()
|
|
if san_lower == hostname_lower:
|
|
return True
|
|
if san_lower.startswith("*."):
|
|
# Wildcard: *.example.com matches foo.example.com only
|
|
suffix = san_lower[1:] # ".example.com"
|
|
if (
|
|
hostname_lower.endswith(suffix)
|
|
and "." not in hostname_lower[: -len(suffix)]
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core check function
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_tls_certificate(
|
|
hostname: str,
|
|
port: int = 443,
|
|
warn_days: int = 30,
|
|
timeout: float = 10.0,
|
|
ssl_context: ssl.SSLContext | None = None,
|
|
) -> CertCheckResult:
|
|
"""Perform a full TLS certificate check for *hostname*:*port*.
|
|
|
|
Args:
|
|
hostname: The hostname to connect to and verify.
|
|
port: TCP port (default 443).
|
|
warn_days: Warn if the certificate expires within this many days.
|
|
timeout: Socket connection timeout in seconds.
|
|
ssl_context: Optional custom SSLContext (used in tests to inject
|
|
mock behaviour). When ``None``, a default context
|
|
with system CA verification is used.
|
|
|
|
Returns:
|
|
A :class:`CertCheckResult` describing the outcome.
|
|
"""
|
|
result = CertCheckResult(hostname=hostname, port=port)
|
|
|
|
# Build default SSL context if none provided
|
|
ctx = ssl.create_default_context() if ssl_context is None else ssl_context
|
|
|
|
# --- Attempt TLS handshake ---
|
|
try:
|
|
with (
|
|
socket.create_connection((hostname, port), timeout=timeout) as raw_sock,
|
|
ctx.wrap_socket(raw_sock, server_hostname=hostname) as tls_sock,
|
|
):
|
|
cert = tls_sock.getpeercert()
|
|
except ssl.SSLCertVerificationError as exc:
|
|
reason = getattr(exc, "reason", None) or str(exc)
|
|
result.add_error(f"TLS verification failed: {reason}")
|
|
return result
|
|
except ssl.SSLError as exc:
|
|
result.add_error(f"TLS handshake error: {exc}")
|
|
return result
|
|
except TimeoutError:
|
|
result.add_error(f"Connection timed out after {timeout}s")
|
|
return result
|
|
except OSError as exc:
|
|
result.add_error(f"Connection failed: {exc}")
|
|
return result
|
|
|
|
if cert is None:
|
|
result.add_error("No certificate returned by server")
|
|
return result
|
|
|
|
# --- Parse certificate fields ---
|
|
result.subject_cn = _extract_cn(cert)
|
|
result.sans = _extract_sans(cert)
|
|
result.not_after = _parse_not_after(cert)
|
|
|
|
# --- SAN check ---
|
|
if not _hostname_matches_san(hostname, result.sans):
|
|
result.add_error(
|
|
f"Hostname '{hostname}' not found in certificate SANs: "
|
|
f"{result.sans!r}. The certificate may be missing this hostname "
|
|
"or SNI routing is misconfigured."
|
|
)
|
|
|
|
# --- Expiry check ---
|
|
if result.not_after is not None:
|
|
now = datetime.datetime.now(tz=datetime.UTC)
|
|
delta = result.not_after - now
|
|
result.days_remaining = delta.days
|
|
if delta.total_seconds() <= 0:
|
|
expiry = result.not_after.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
result.add_error(f"Certificate expired on {expiry}")
|
|
elif delta.days <= warn_days:
|
|
expiry_date = result.not_after.strftime("%Y-%m-%d")
|
|
result.add_warning(
|
|
f"Certificate expires in {delta.days} day(s) "
|
|
f"({expiry_date}). "
|
|
f"Renewal recommended (threshold: {warn_days} days)."
|
|
)
|
|
else:
|
|
result.add_warning("Could not determine certificate expiry date")
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reporting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _print_result(result: CertCheckResult) -> None:
|
|
"""Print a human-readable summary of a :class:`CertCheckResult`."""
|
|
status = "OK" if result.ok else "FAIL"
|
|
print(f"\n[{status}] {result.hostname}:{result.port}")
|
|
if result.subject_cn:
|
|
print(f" Subject CN : {result.subject_cn}")
|
|
if result.sans:
|
|
print(f" SANs : {', '.join(result.sans)}")
|
|
if result.not_after:
|
|
days = result.days_remaining
|
|
expiry = result.not_after.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
print(f" Expires : {expiry} ({days} day(s) remaining)")
|
|
for err in result.errors:
|
|
print(f" ERROR : {err}")
|
|
for warn in result.warnings:
|
|
print(f" WARNING : {warn}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
"""Parse arguments and run TLS checks. Returns the process exit code."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Check TLS certificate validity for one or more hostnames.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
parser.add_argument(
|
|
"hostnames",
|
|
nargs="+",
|
|
metavar="HOSTNAME",
|
|
help="One or more hostnames to check (e.g. git.dev.cleveragents.com)",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=443,
|
|
help="HTTPS port to connect to (default: 443)",
|
|
)
|
|
parser.add_argument(
|
|
"--warn-days",
|
|
type=int,
|
|
default=30,
|
|
metavar="DAYS",
|
|
help="Warn if certificate expires within DAYS days (default: 30)",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=10.0,
|
|
metavar="SECONDS",
|
|
help="Connection timeout in seconds (default: 10)",
|
|
)
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
all_ok = True
|
|
for hostname in args.hostnames:
|
|
result = check_tls_certificate(
|
|
hostname=hostname,
|
|
port=args.port,
|
|
warn_days=args.warn_days,
|
|
timeout=args.timeout,
|
|
)
|
|
_print_result(result)
|
|
if not result.ok:
|
|
all_ok = False
|
|
|
|
print()
|
|
if all_ok:
|
|
print("All TLS checks PASSED")
|
|
return 0
|
|
else:
|
|
print("One or more TLS checks FAILED")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|