1399 lines
52 KiB
Python
1399 lines
52 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail-closed, read-only structural and optional live production preflight."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
from urllib.parse import urlparse
|
|
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
from uuid import UUID
|
|
|
|
import yaml # type: ignore[import-untyped]
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BASE_COMPOSE = ROOT / "infrastructure/compose/docker-compose.yml"
|
|
PROD_COMPOSE = ROOT / "infrastructure/compose/docker-compose.prod.yml"
|
|
INPUT_MAP = ROOT / "infrastructure/compose/production-input-map.json"
|
|
CONTEXTFORGE_CONTRACT = ROOT / "infrastructure/images/contextforge/runtime-contract.json"
|
|
VARIABLE = re.compile(r"\$\{([A-Z][A-Z0-9_]*)(?:(:-|:\?)([^}]*))?\}")
|
|
IMAGE_DIGEST = re.compile(r"@sha256:[0-9a-f]{64}$")
|
|
CONTEXTFORGE_PRODUCTION_IMAGE = "itworx-mcp-hub/contextforge-schema:12d4a0c7789c"
|
|
|
|
|
|
def _contextforge_contract_checks(
|
|
environment: dict[str, str],
|
|
topology: dict[str, Any],
|
|
results: list[dict[str, str]],
|
|
) -> bool:
|
|
try:
|
|
raw_contract = CONTEXTFORGE_CONTRACT.read_bytes()
|
|
contract = json.loads(raw_contract)
|
|
except (OSError, json.JSONDecodeError):
|
|
return _check(
|
|
results,
|
|
"contextforge.runtime-contract",
|
|
False,
|
|
"ContextForge runtime contract is unreadable",
|
|
)
|
|
|
|
expected_digest = hashlib.sha256(raw_contract).hexdigest()
|
|
service = topology.get("services", {}).get("contextforge", {})
|
|
service_environment = service.get("environment", {})
|
|
contract_environment = contract.get("productionEnvironment", {})
|
|
|
|
configured_digest = str(
|
|
service_environment.get("ITWORX_CONTEXTFORGE_EXPECTED_CONTRACT_SHA256") or ""
|
|
)
|
|
digest_ok = (
|
|
re.fullmatch(r"[0-9a-f]{64}", configured_digest) is not None
|
|
and configured_digest == expected_digest
|
|
)
|
|
image_ok = (
|
|
service.get("image") == CONTEXTFORGE_PRODUCTION_IMAGE
|
|
and service.get("pull_policy") == "never"
|
|
and "build" not in service
|
|
)
|
|
environment_ok = isinstance(contract_environment, dict) and all(
|
|
isinstance(name, str) and isinstance(value, str) and service_environment.get(name) == value
|
|
for name, value in contract_environment.items()
|
|
)
|
|
migration_policy = contract.get("migrationPolicy", {})
|
|
migration_ok = (
|
|
isinstance(migration_policy, dict)
|
|
and migration_policy.get("startupMigrations") == "forbidden"
|
|
and migration_policy.get("skipEnv") == "MCPGATEWAY_SKIP_MIGRATIONS"
|
|
and migration_policy.get("skipValue") == "true"
|
|
and service_environment.get("MCPGATEWAY_SKIP_MIGRATIONS") == "true"
|
|
)
|
|
required_runtime_mounts = {
|
|
"../images/contextforge/entrypoint.sh:/opt/itworx/contextforge-entrypoint.sh:ro",
|
|
"../images/contextforge/runtime-contract.json:/opt/itworx/contextforge-runtime-contract.json:ro",
|
|
"../../scripts/contextforge_runtime_contract.py:/opt/itworx/contextforge-runtime-contract.py:ro",
|
|
}
|
|
runtime_mounts = {str(item) for item in service.get("volumes", []) if isinstance(item, str)}
|
|
source_bound_runtime_ok = runtime_mounts == required_runtime_mounts and len(
|
|
service.get("volumes", [])
|
|
) == len(required_runtime_mounts)
|
|
healthcheck = json.dumps(service.get("healthcheck", {}), sort_keys=True)
|
|
health_ok = (
|
|
"contextforge-runtime-contract.py --health" in healthcheck
|
|
and "127.0.0.1:4444/health" in healthcheck
|
|
)
|
|
contract_shape_ok = (
|
|
contract.get("schema") == "itworx.contextforge-runtime-contract/v1"
|
|
and re.fullmatch(r"[0-9a-f]{40}", str(contract.get("sourceCommit", ""))) is not None
|
|
and bool(str(contract.get("alembicHead", "")))
|
|
and bool(str(contract.get("mcpSdk", "")))
|
|
and contract.get("requiredSymbol") == "McpError"
|
|
)
|
|
ok = (
|
|
digest_ok
|
|
and image_ok
|
|
and environment_ok
|
|
and migration_ok
|
|
and source_bound_runtime_ok
|
|
and health_ok
|
|
and contract_shape_ok
|
|
)
|
|
return _check(
|
|
results,
|
|
"contextforge.runtime-contract",
|
|
ok,
|
|
"ContextForge production image, contract, exact-source runtime mounts and health gate are exact"
|
|
if ok
|
|
else "ContextForge runtime contract, digest or fail-closed production wiring drifted",
|
|
)
|
|
|
|
|
|
class _RejectOidcDiscoveryRedirects(HTTPRedirectHandler):
|
|
"""Prevent discovery from contacting any redirect target."""
|
|
|
|
def redirect_request(
|
|
self,
|
|
req: Request,
|
|
fp: Any,
|
|
code: int,
|
|
msg: str,
|
|
headers: Any,
|
|
newurl: str,
|
|
) -> None:
|
|
return None
|
|
|
|
|
|
_OIDC_DISCOVERY_OPENER = build_opener(_RejectOidcDiscoveryRedirects())
|
|
PLACEHOLDER = re.compile(
|
|
r"(?:REPLACE|CHANGEME|<[^>]+>|example\.(?:com|invalid)|\.invalid(?:/|$)|local/|:dev(?:@|$))",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
_RESET = object()
|
|
|
|
|
|
class _ComposeMergeLoader(yaml.SafeLoader): # type: ignore[misc]
|
|
"""SafeLoader that understands the Compose Specification's `!reset` merge tag."""
|
|
|
|
|
|
def _construct_reset(loader: yaml.SafeLoader, node: yaml.Node) -> object:
|
|
return _RESET
|
|
|
|
|
|
_ComposeMergeLoader.add_constructor("!reset", _construct_reset)
|
|
|
|
REQUIRED_ENV = (
|
|
"UNRAID_HOST",
|
|
"APPDATA_ROOT",
|
|
"SECRETS_DIR",
|
|
"PROJECTS_READONLY_ROOT",
|
|
"KNOWLEDGE_SOURCES_JSON",
|
|
"BACKUP_ROOT",
|
|
"UNRAID_REGISTRATIONS_FILE",
|
|
"HUB_BIND_IP",
|
|
"MCP_BIND_IP",
|
|
"HUB_PORT",
|
|
"MCP_PORT",
|
|
"PUID",
|
|
"PGID",
|
|
"DOCKER_SOCKET_GID",
|
|
"REGISTRY_HOST",
|
|
"REGISTRY_NAMESPACE",
|
|
"REGISTRY_CREDENTIAL_REF",
|
|
"HUB_OIDC_ISSUER",
|
|
"HUB_OIDC_AUDIENCE",
|
|
"HUB_OIDC_CLIENT_ID",
|
|
"HUB_OIDC_REDIRECT_URI",
|
|
"HUB_OIDC_ROLE_GROUP_MAP",
|
|
"HUB_MCP_ADDITIONAL_OIDC_ISSUER",
|
|
"HUB_MCP_ADDITIONAL_OIDC_AUDIENCE",
|
|
"HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID",
|
|
"HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI",
|
|
"HUB_PUBLIC_MCP_URL",
|
|
"HUB_RATE_LIMIT_PER_MINUTE",
|
|
"AUTHENTIK_PROVIDER_ID",
|
|
"AUTHENTIK_HUB_APPLICATION_ID",
|
|
"AUTHENTIK_SCOPES",
|
|
"AUTHENTIK_ALLOWED_GROUPS",
|
|
"AUTHENTIK_SERVICE_ACCOUNTS",
|
|
"MCP_CLIENT_BINDINGS_JSON",
|
|
"HUB_CONTEXTFORGE_URL",
|
|
"CONTEXTFORGE_DB_HOST",
|
|
"CONTEXTFORGE_DB_NAME",
|
|
"CONTEXTFORGE_DB_USER",
|
|
"REDIS_AUTH_ENABLED",
|
|
"GITEA_ENDPOINT",
|
|
"GITEA_REPOSITORIES_JSON",
|
|
"GITEA_AUTO_DISCOVER_PROJECT",
|
|
"GITEA_TOKEN_SCOPE",
|
|
"GITEA_WRITE_TOKEN_SCOPE",
|
|
"GITEA_ENABLE_PR_WRITES",
|
|
"MOBILITYOPS_ENDPOINT",
|
|
"MOBILITYOPS_PROJECTS_JSON",
|
|
"PROJECTBRAIN_ENDPOINT",
|
|
"PROJECTBRAIN_PROJECTS",
|
|
"APPOPS_ENDPOINT",
|
|
"APPOPS_PROJECTS",
|
|
"NPM_NETWORK_NAME",
|
|
"NPM_HUB_PROXY_HOST",
|
|
"NPM_MCP_PROXY_HOST",
|
|
"NPM_CERTIFICATE_REF",
|
|
"DNS_HUB_HOSTNAME",
|
|
"DNS_MCP_HOSTNAME",
|
|
"DNS_EXPECTED_ADDRESSES_JSON",
|
|
"NETWORK_TARGETS_JSON",
|
|
"DEMO_TOKEN_REVOCATIONS_DIR_HOST",
|
|
)
|
|
|
|
REQUIRED_SECRET_FILES = (
|
|
"postgres_superuser_password",
|
|
"hub_database_password",
|
|
"contextforge_database_password",
|
|
"contextforge_auth_encryption_secret",
|
|
"contextforge_jwt_secret",
|
|
"contextforge_admin_credential",
|
|
"docker_broker_service_token",
|
|
"connector_identity_signing_key",
|
|
"demo_token_signing_key",
|
|
"authentik_oidc_client_secret",
|
|
"hub_session_signing_key",
|
|
"gitea_mcp_token",
|
|
"gitea_mcp_write_token",
|
|
"mobilityops_service_token",
|
|
"projectbrain_hub_service_token",
|
|
"appops_hub_service_token",
|
|
"stitch_api_key",
|
|
"redis_password",
|
|
)
|
|
|
|
GENERATED_SECRET_FILES = ("contextforge_api_token",)
|
|
|
|
IMAGE_ENVIRONMENT = {
|
|
"control-api": "HUB_CONTROL_IMAGE",
|
|
"worker": "HUB_WORKER_IMAGE",
|
|
"dataplane-router": "HUB_DATAPLANE_IMAGE",
|
|
"web": "HUB_WEB_IMAGE",
|
|
"connector-knowledge": "CONNECTOR_KNOWLEDGE_IMAGE",
|
|
"connector-gitea": "CONNECTOR_GITEA_IMAGE",
|
|
"connector-unraid": "CONNECTOR_UNRAID_IMAGE",
|
|
"connector-mobilityops": "CONNECTOR_MOBILITYOPS_IMAGE",
|
|
"connector-projectbrain": "CONNECTOR_PROJECTBRAIN_IMAGE",
|
|
"connector-appops": "CONNECTOR_APPOPS_IMAGE",
|
|
"connector-stitch": "CONNECTOR_STITCH_IMAGE",
|
|
"docker-read-broker": "DOCKER_READ_BROKER_IMAGE",
|
|
"observability-collector": "OBSERVABILITY_COLLECTOR_IMAGE",
|
|
}
|
|
|
|
REQUIRED_SERVICES = {
|
|
"postgres",
|
|
"redis",
|
|
"contextforge",
|
|
"control-api",
|
|
"worker",
|
|
"dataplane-router",
|
|
"web",
|
|
"connector-knowledge",
|
|
"connector-gitea",
|
|
"connector-unraid",
|
|
"connector-projectbrain",
|
|
"connector-appops",
|
|
"connector-stitch",
|
|
"docker-read-broker",
|
|
"observability-collector",
|
|
}
|
|
|
|
OWNED_NETWORKS = (
|
|
"itworx-mcp-hub-edge",
|
|
"itworx-mcp-hub-egress",
|
|
"itworx-mcp-hub-control",
|
|
"itworx-mcp-hub-connectors",
|
|
"itworx-mcp-hub-broker",
|
|
"itworx-mcp-hub-data",
|
|
"itworx-mcp-hub-observe",
|
|
)
|
|
|
|
|
|
def load_env_file(path: Path) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("export "):
|
|
line = line[7:].lstrip()
|
|
if "=" not in line:
|
|
raise ValueError(f"malformed env-file line {line_number}")
|
|
name, value = line.split("=", 1)
|
|
name = name.strip()
|
|
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", name):
|
|
raise ValueError(f"invalid env name on line {line_number}")
|
|
values[name] = value.strip().strip('"').strip("'")
|
|
return values
|
|
|
|
|
|
def _check(results: list[dict[str, str]], check_id: str, passed: bool, detail: str) -> bool:
|
|
results.append({"id": check_id, "status": "pass" if passed else "fail", "detail": detail})
|
|
return passed
|
|
|
|
|
|
def _defer(results: list[dict[str, str]], check_id: str, detail: str) -> None:
|
|
results.append({"id": check_id, "status": "deferred", "detail": detail})
|
|
|
|
|
|
def _contains_placeholder(value: str) -> bool:
|
|
return not value.strip() or bool(PLACEHOLDER.search(value)) or "${" in value
|
|
|
|
|
|
def _required_values(environment: dict[str, str], results: list[dict[str, str]]) -> bool:
|
|
required = [*REQUIRED_ENV, *IMAGE_ENVIRONMENT.values()]
|
|
missing = [name for name in required if not environment.get(name, "").strip()]
|
|
placeholders = [
|
|
name
|
|
for name in required
|
|
if environment.get(name, "").strip() and _contains_placeholder(environment[name])
|
|
]
|
|
return _check(
|
|
results,
|
|
"inputs.required",
|
|
not missing and not placeholders,
|
|
"all required inputs are non-empty and non-placeholder"
|
|
if not missing and not placeholders
|
|
else f"{len(missing)} missing and {len(placeholders)} placeholder input(s)",
|
|
)
|
|
|
|
|
|
def _decode_json(value: str, expected: type[Any]) -> Any | None:
|
|
try:
|
|
decoded = json.loads(value)
|
|
except (TypeError, json.JSONDecodeError):
|
|
return None
|
|
return decoded if isinstance(decoded, expected) and bool(decoded) else None
|
|
|
|
|
|
def _structured_input_checks(environment: dict[str, str], results: list[dict[str, str]]) -> bool:
|
|
expected_types: dict[str, type[Any]] = {
|
|
"HUB_OIDC_ROLE_GROUP_MAP": dict,
|
|
"AUTHENTIK_SERVICE_ACCOUNTS": list,
|
|
"MCP_CLIENT_BINDINGS_JSON": list,
|
|
"KNOWLEDGE_SOURCES_JSON": list,
|
|
"GITEA_REPOSITORIES_JSON": list,
|
|
"DNS_EXPECTED_ADDRESSES_JSON": dict,
|
|
"NETWORK_TARGETS_JSON": dict,
|
|
}
|
|
valid = True
|
|
for name, expected_type in expected_types.items():
|
|
decoded = _decode_json(environment.get(name, ""), expected_type)
|
|
ok = decoded is not None and not _contains_placeholder(json.dumps(decoded))
|
|
valid &= _check(
|
|
results,
|
|
f"inputs.{name.lower()}",
|
|
ok,
|
|
"structured input is non-empty and contains no placeholders"
|
|
if ok
|
|
else "structured input is invalid, empty, or contains placeholders",
|
|
)
|
|
sources = _decode_json(environment.get("KNOWLEDGE_SOURCES_JSON", ""), list) or []
|
|
source_root = Path(environment.get("PROJECTS_READONLY_ROOT", ""))
|
|
source_shape = all(
|
|
isinstance(item, dict)
|
|
and isinstance(item.get("project"), str)
|
|
and isinstance(item.get("sourceRef"), str)
|
|
and isinstance(item.get("path"), str)
|
|
and item["project"]
|
|
and item["sourceRef"]
|
|
and not Path(item["path"]).is_absolute()
|
|
and ".." not in Path(item["path"]).parts
|
|
for item in sources
|
|
)
|
|
source_paths = False
|
|
if sources and source_shape and source_root.is_dir():
|
|
try:
|
|
resolved_root = source_root.resolve(strict=True)
|
|
source_paths = all(
|
|
resolved_root == (resolved_root / item["path"]).resolve(strict=True)
|
|
or resolved_root in (resolved_root / item["path"]).resolve(strict=True).parents
|
|
for item in sources
|
|
)
|
|
except OSError:
|
|
source_paths = False
|
|
valid &= _check(
|
|
results,
|
|
"inputs.knowledge-sources-shape",
|
|
bool(sources) and source_shape and source_paths,
|
|
"knowledge sources are non-empty registered relative roots that exist"
|
|
if sources and source_shape and source_paths
|
|
else (
|
|
"knowledge sources must contain project, sourceRef, and an existing safe relative path"
|
|
),
|
|
)
|
|
return valid
|
|
|
|
|
|
def _path_checks(
|
|
environment: dict[str, str], results: list[dict[str, str]], phase: str
|
|
) -> tuple[bool, list[bytes]]:
|
|
valid = True
|
|
for name in ("APPDATA_ROOT", "SECRETS_DIR", "PROJECTS_READONLY_ROOT", "BACKUP_ROOT"):
|
|
path = Path(environment.get(name, ""))
|
|
exists = path.is_dir()
|
|
valid &= _check(
|
|
results,
|
|
f"path.{name.lower()}",
|
|
exists,
|
|
"directory exists" if exists else "required directory is missing",
|
|
)
|
|
if exists and os.name != "nt":
|
|
safe = not stat.S_IMODE(path.stat().st_mode) & 0o022
|
|
valid &= _check(
|
|
results,
|
|
f"permissions.{name.lower()}",
|
|
safe,
|
|
"directory is not group/world writable" if safe else "unsafe directory mode",
|
|
)
|
|
registrations = Path(environment.get("UNRAID_REGISTRATIONS_FILE", ""))
|
|
valid &= _check(
|
|
results,
|
|
"path.unraid-registrations-file",
|
|
registrations.is_file(),
|
|
"broker registration file exists"
|
|
if registrations.is_file()
|
|
else "broker registration file is missing",
|
|
)
|
|
revocation_directory = Path(environment.get("DEMO_TOKEN_REVOCATIONS_DIR_HOST", ""))
|
|
revocation_file = revocation_directory / "demo-token-revocations.json"
|
|
directory_ok = revocation_directory.is_dir()
|
|
if directory_ok and os.name != "nt":
|
|
directory_ok = not stat.S_IMODE(revocation_directory.stat().st_mode) & 0o022
|
|
valid &= _check(
|
|
results,
|
|
"path.demo-token-revocations-directory",
|
|
directory_ok,
|
|
"revocation directory exists and is not group/world writable"
|
|
if directory_ok
|
|
else "revocation directory is missing or unsafe",
|
|
)
|
|
file_ok = revocation_file.is_file()
|
|
if file_ok and os.name != "nt":
|
|
file_ok = stat.S_IMODE(revocation_file.stat().st_mode) == 0o600
|
|
valid &= _check(
|
|
results,
|
|
"path.demo-token-revocations-file",
|
|
file_ok,
|
|
"revocation file is a regular 0600 file"
|
|
if file_ok
|
|
else "revocation file is missing, not regular, or has an unsafe mode",
|
|
)
|
|
secrets = Path(environment.get("SECRETS_DIR", ""))
|
|
if secrets.is_dir() and os.name != "nt":
|
|
directory_ok = stat.S_IMODE(secrets.stat().st_mode) == 0o700
|
|
valid &= _check(
|
|
results,
|
|
"permissions.secrets-directory",
|
|
directory_ok,
|
|
"secret directory mode is 0700" if directory_ok else "secret directory must be 0700",
|
|
)
|
|
try:
|
|
expected_uid = int(environment.get("PUID", "-1"))
|
|
expected_gid = int(environment.get("PGID", "-1"))
|
|
socket_gid = int(environment.get("DOCKER_SOCKET_GID", "-1"))
|
|
ids_ok = expected_uid >= 0 and expected_gid >= 0 and socket_gid >= 0
|
|
except ValueError:
|
|
expected_uid = expected_gid = -1
|
|
ids_ok = False
|
|
valid &= _check(
|
|
results,
|
|
"identity.numeric",
|
|
ids_ok,
|
|
"PUID, PGID, and Docker socket GID are numeric"
|
|
if ids_ok
|
|
else "PUID, PGID, and Docker socket GID must be numeric",
|
|
)
|
|
secret_values: list[bytes] = []
|
|
secret_names = [*REQUIRED_SECRET_FILES]
|
|
if phase == "poststart":
|
|
secret_names.extend(GENERATED_SECRET_FILES)
|
|
else:
|
|
for name in GENERATED_SECRET_FILES:
|
|
_defer(results, f"secret.{name}", "generated by the ContextForge bootstrap step")
|
|
for name in secret_names:
|
|
path = secrets / name
|
|
present = path.is_file()
|
|
mode_ok = present and (os.name == "nt" or stat.S_IMODE(path.stat().st_mode) == 0o600)
|
|
owner_ok = present and (
|
|
os.name == "nt"
|
|
or (path.stat().st_uid == expected_uid and path.stat().st_gid == expected_gid)
|
|
)
|
|
content_ok = False
|
|
if present:
|
|
try:
|
|
payload = path.read_bytes()
|
|
content_ok = bool(payload.rstrip(b"\r\n")) and len(payload) <= 8192
|
|
content_ok = (
|
|
content_ok and b"\x00" not in payload and b"\r" not in payload.rstrip(b"\r\n")
|
|
)
|
|
content_ok = content_ok and b"\n" not in payload.rstrip(b"\r\n")
|
|
if name in ("connector_identity_signing_key", "demo_token_signing_key"):
|
|
content_ok = content_ok and len(payload.rstrip(b"\r\n")) >= 32
|
|
if content_ok:
|
|
secret_values.append(payload.rstrip(b"\r\n"))
|
|
except OSError:
|
|
content_ok = False
|
|
ok = present and mode_ok and owner_ok and content_ok
|
|
valid &= _check(
|
|
results,
|
|
f"secret.{name}",
|
|
ok,
|
|
"secret file satisfies owner, mode, and bounded-content policy"
|
|
if ok
|
|
else "secret file is missing, empty, unreadable, wrongly owned, or unsafe",
|
|
)
|
|
return valid, secret_values
|
|
|
|
|
|
def _substitute(value: str, environment: dict[str, str]) -> str:
|
|
def replace(match: re.Match[str]) -> str:
|
|
name, operator, fallback = match.groups()
|
|
current = environment.get(name, "")
|
|
if current:
|
|
return current
|
|
if operator == ":-":
|
|
return fallback or ""
|
|
return match.group(0)
|
|
|
|
return VARIABLE.sub(replace, value)
|
|
|
|
|
|
def _render(value: Any, environment: dict[str, str]) -> Any:
|
|
if isinstance(value, str):
|
|
return _substitute(value, environment)
|
|
if isinstance(value, list):
|
|
return [_render(item, environment) for item in value]
|
|
if isinstance(value, dict):
|
|
return {key: _render(item, environment) for key, item in value.items()}
|
|
return value
|
|
|
|
|
|
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
result = copy.deepcopy(base)
|
|
for key, value in overlay.items():
|
|
if value is _RESET:
|
|
result.pop(key, None)
|
|
elif isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
result[key] = _deep_merge(result[key], value)
|
|
else:
|
|
result[key] = copy.deepcopy(value)
|
|
return result
|
|
|
|
|
|
def _rendered_topology(environment: dict[str, str]) -> dict[str, Any]:
|
|
# _ComposeMergeLoader subclasses yaml.SafeLoader and only adds the !reset tag.
|
|
base = yaml.load(BASE_COMPOSE.read_text(encoding="utf-8"), Loader=_ComposeMergeLoader) # noqa: S506
|
|
production = yaml.load(PROD_COMPOSE.read_text(encoding="utf-8"), Loader=_ComposeMergeLoader) # noqa: S506
|
|
rendered = _render(_deep_merge(base, production), environment)
|
|
enabled_profiles = {
|
|
item.strip() for item in environment.get("COMPOSE_PROFILES", "").split(",") if item.strip()
|
|
}
|
|
if "inspector" not in enabled_profiles:
|
|
rendered.get("services", {}).pop("inspector", None)
|
|
return cast(dict[str, Any], rendered)
|
|
|
|
|
|
def _mapping_checks(topology: dict[str, Any], results: list[dict[str, str]]) -> bool:
|
|
try:
|
|
mapping = json.loads(INPUT_MAP.read_text(encoding="utf-8"))
|
|
inputs = mapping["inputs"]
|
|
except (OSError, KeyError, json.JSONDecodeError):
|
|
return _check(results, "mapping.readable", False, "production input map is invalid")
|
|
required_fields = {
|
|
"name",
|
|
"classification",
|
|
"source",
|
|
"reference",
|
|
"consumers",
|
|
"validation",
|
|
"rendered",
|
|
"phase",
|
|
}
|
|
complete = bool(inputs) and all(required_fields <= set(item) for item in inputs)
|
|
references = " ".join(str(item.get("reference", "")) for item in inputs)
|
|
covered = all(name in references for name in REQUIRED_ENV)
|
|
services = topology.get("services", {})
|
|
consumers_exist = all(
|
|
consumer in services
|
|
for item in inputs
|
|
for consumer in item.get("consumers", [])
|
|
if consumer in REQUIRED_SERVICES
|
|
)
|
|
return _check(
|
|
results,
|
|
"mapping.complete",
|
|
complete and covered and consumers_exist,
|
|
"required inputs have source, reference, consumer, validation, render policy, and phase"
|
|
if complete and covered and consumers_exist
|
|
else "production input map is incomplete or names an unknown service",
|
|
)
|
|
|
|
|
|
def _image_checks(
|
|
environment: dict[str, str],
|
|
topology: dict[str, Any],
|
|
results: list[dict[str, str]],
|
|
verify_registry: bool,
|
|
) -> bool:
|
|
valid = _contextforge_contract_checks(environment, topology, results)
|
|
registry = environment.get("REGISTRY_HOST", "").rstrip("/")
|
|
namespace = environment.get("REGISTRY_NAMESPACE", "").strip("/")
|
|
prefix = f"{registry}/{namespace}/"
|
|
for service, variable in IMAGE_ENVIRONMENT.items():
|
|
image = str(topology.get("services", {}).get(service, {}).get("image", ""))
|
|
immutable = bool(IMAGE_DIGEST.search(image)) and image.startswith(prefix)
|
|
immutable = immutable and "latest" not in image.lower() and "local/" not in image
|
|
valid &= _check(
|
|
results,
|
|
f"image.{service}",
|
|
immutable,
|
|
f"{variable} resolves to an immutable registry digest"
|
|
if immutable
|
|
else "owned image must resolve to the configured registry and immutable digest",
|
|
)
|
|
if verify_registry and immutable:
|
|
docker = shutil.which("docker")
|
|
inspected = False
|
|
if docker:
|
|
completed = subprocess.run( # noqa: S603
|
|
[docker, "manifest", "inspect", image],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
inspected = completed.returncode == 0
|
|
valid &= _check(
|
|
results,
|
|
f"registry.{service}",
|
|
inspected,
|
|
"registry manifest is reachable" if inspected else "manifest inspection failed",
|
|
)
|
|
elif not verify_registry:
|
|
_defer(results, f"registry.{service}", "enable --verify-registry to contact registry")
|
|
return valid
|
|
|
|
|
|
def _service_secret_names(service: dict[str, Any]) -> set[str]:
|
|
return {
|
|
str(item if isinstance(item, str) else item.get("source", ""))
|
|
for item in service.get("secrets", [])
|
|
}
|
|
|
|
|
|
def _topology_checks(
|
|
topology: dict[str, Any], secret_values: list[bytes], results: list[dict[str, str]]
|
|
) -> bool:
|
|
services = topology.get("services", {})
|
|
valid = _check(
|
|
results,
|
|
"compose.required-services",
|
|
REQUIRED_SERVICES <= set(services),
|
|
"all required production services are present"
|
|
if REQUIRED_SERVICES <= set(services)
|
|
else "required production service is missing",
|
|
)
|
|
unresolved = "${" in json.dumps(topology, sort_keys=True)
|
|
valid &= _check(
|
|
results,
|
|
"compose.resolved",
|
|
not unresolved,
|
|
"rendered topology contains no unresolved variables"
|
|
if not unresolved
|
|
else "rendered topology contains unresolved variables",
|
|
)
|
|
socket_users = [
|
|
name
|
|
for name, service in services.items()
|
|
if "/var/run/docker.sock" in json.dumps(service.get("volumes", []))
|
|
]
|
|
valid &= _check(
|
|
results,
|
|
"compose.docker-socket",
|
|
socket_users == ["docker-read-broker"],
|
|
"only docker-read-broker receives the read-only socket"
|
|
if socket_users == ["docker-read-broker"]
|
|
else "Docker socket access violates the broker boundary",
|
|
)
|
|
ports: set[tuple[str, int]] = set()
|
|
ports_ok = True
|
|
for name, service in services.items():
|
|
published = service.get("ports", [])
|
|
if published and name not in {"web", "dataplane-router", "inspector"}:
|
|
ports_ok = False
|
|
for raw in published:
|
|
parts = str(raw).split(":")
|
|
if len(parts) != 3:
|
|
ports_ok = False
|
|
continue
|
|
host, port_text, _ = parts
|
|
try:
|
|
port = int(port_text)
|
|
except ValueError:
|
|
ports_ok = False
|
|
continue
|
|
key = (host, port)
|
|
if (
|
|
host in {"0.0.0.0", "::", ""} # noqa: S104 - fail-closed rejection.
|
|
or not 1 <= port <= 65535
|
|
or key in ports
|
|
):
|
|
ports_ok = False
|
|
ports.add(key)
|
|
valid &= _check(
|
|
results,
|
|
"compose.ports",
|
|
ports_ok,
|
|
"published ports are explicit, unique, and limited to edge services"
|
|
if ports_ok
|
|
else "ports are duplicate, wildcard, malformed, or exposed by an internal service",
|
|
)
|
|
gitea = services.get("connector-gitea", {})
|
|
gitea_ok = (
|
|
not gitea.get("ports")
|
|
and set(gitea.get("networks", [])) == {"hub-connectors", "hub-observe", "hub-egress"}
|
|
and gitea.get("read_only") is True
|
|
and gitea.get("cap_drop") == ["ALL"]
|
|
and "no-new-privileges:true" in gitea.get("security_opt", [])
|
|
and bool(gitea.get("tmpfs"))
|
|
and bool(gitea.get("pids_limit"))
|
|
and bool(gitea.get("mem_limit"))
|
|
and bool(gitea.get("cpus"))
|
|
and bool(gitea.get("healthcheck"))
|
|
and bool(gitea.get("depends_on", {}).get("contextforge"))
|
|
and "gitea_mcp_token" in _service_secret_names(gitea)
|
|
and "gitea_mcp_write_token" in _service_secret_names(gitea)
|
|
and "/run/secrets/gitea_mcp_token" == gitea.get("environment", {}).get("GITEA_TOKEN_FILE")
|
|
and "/run/secrets/gitea_mcp_write_token"
|
|
== gitea.get("environment", {}).get("GITEA_WRITE_TOKEN_FILE")
|
|
and gitea.get("environment", {}).get("GITEA_ENABLE_PR_WRITES") == "true"
|
|
and bool(gitea.get("user"))
|
|
)
|
|
valid &= _check(
|
|
results,
|
|
"compose.connector-gitea",
|
|
gitea_ok,
|
|
"Gitea connector is internal, hardened, dependent, and file-secret wired"
|
|
if gitea_ok
|
|
else "Gitea connector production wiring is incomplete",
|
|
)
|
|
required_mounts = {
|
|
"control-api": {
|
|
"authentik_oidc_client_secret",
|
|
"hub_session_signing_key",
|
|
"contextforge_api_token",
|
|
"hub_database_password",
|
|
},
|
|
"worker": {"contextforge_api_token", "hub_database_password"},
|
|
"dataplane-router": {"contextforge_api_token", "hub_database_password"},
|
|
"contextforge": {
|
|
"contextforge_database_password",
|
|
"contextforge_auth_encryption_secret",
|
|
"contextforge_jwt_secret",
|
|
"contextforge_admin_credential",
|
|
"redis_password",
|
|
},
|
|
"postgres": {
|
|
"postgres_superuser_password",
|
|
"hub_database_password",
|
|
"contextforge_database_password",
|
|
},
|
|
"redis": {"redis_password"},
|
|
"connector-gitea": {"gitea_mcp_token", "gitea_mcp_write_token"},
|
|
"connector-appops": {"appops_hub_service_token"},
|
|
"docker-read-broker": {"docker_broker_service_token"},
|
|
"connector-unraid": {"docker_broker_service_token"},
|
|
}
|
|
mounts_ok = all(
|
|
expected <= _service_secret_names(services.get(name, {}))
|
|
for name, expected in required_mounts.items()
|
|
) and set((*REQUIRED_SECRET_FILES, *GENERATED_SECRET_FILES)) <= set(topology.get("secrets", {}))
|
|
valid &= _check(
|
|
results,
|
|
"compose.secret-consumers",
|
|
mounts_ok,
|
|
"every required secret has an explicit service mount"
|
|
if mounts_ok
|
|
else "required secret declaration or service mount is missing",
|
|
)
|
|
serialized = json.dumps(topology, sort_keys=True).encode()
|
|
leaked = any(value and value in serialized for value in secret_values)
|
|
valid &= _check(
|
|
results,
|
|
"compose.secret-redaction",
|
|
not leaked,
|
|
"no secret value appears in rendered topology"
|
|
if not leaked
|
|
else "a secret value was interpolated into rendered topology",
|
|
)
|
|
return valid
|
|
|
|
|
|
def _url_checks(environment: dict[str, str], results: list[dict[str, str]]) -> bool:
|
|
valid = True
|
|
for name in (
|
|
"HUB_OIDC_ISSUER",
|
|
"HUB_OIDC_REDIRECT_URI",
|
|
"HUB_MCP_ADDITIONAL_OIDC_ISSUER",
|
|
"HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI",
|
|
"GITEA_ENDPOINT",
|
|
"PROJECTBRAIN_ENDPOINT",
|
|
):
|
|
parsed = urlparse(environment.get(name, ""))
|
|
ok = parsed.scheme == "https" and bool(parsed.hostname) and not parsed.username
|
|
ok = ok and not parsed.password and not _contains_placeholder(environment.get(name, ""))
|
|
valid &= _check(
|
|
results,
|
|
f"url.{name.lower()}",
|
|
ok,
|
|
"HTTPS endpoint is syntactically safe"
|
|
if ok
|
|
else "endpoint must be non-placeholder HTTPS",
|
|
)
|
|
contextforge = environment.get("HUB_CONTEXTFORGE_URL", "")
|
|
parsed = urlparse(contextforge)
|
|
contextforge_ok = contextforge == "http://contextforge-control:4444" or (
|
|
parsed.scheme == "https"
|
|
and bool(parsed.hostname)
|
|
and not parsed.username
|
|
and not parsed.password
|
|
)
|
|
valid &= _check(
|
|
results,
|
|
"url.contextforge",
|
|
contextforge_ok,
|
|
"ContextForge endpoint matches approved internal or HTTPS form"
|
|
if contextforge_ok
|
|
else "ContextForge endpoint is malformed or unapproved",
|
|
)
|
|
appops = environment.get("APPOPS_ENDPOINT", "")
|
|
parsed = urlparse(appops)
|
|
appops_ok = (
|
|
parsed.scheme in {"http", "https"}
|
|
and bool(parsed.hostname)
|
|
and not parsed.username
|
|
and not parsed.password
|
|
and parsed.path in {"", "/"}
|
|
and not parsed.query
|
|
and not parsed.fragment
|
|
and not _contains_placeholder(appops)
|
|
)
|
|
if appops_ok and parsed.scheme == "http":
|
|
try:
|
|
appops_ok = ipaddress.ip_address(str(parsed.hostname)).is_private
|
|
except ValueError:
|
|
appops_ok = False
|
|
valid &= _check(
|
|
results,
|
|
"url.appops_endpoint",
|
|
appops_ok,
|
|
"AppOps endpoint is private-IP HTTP or HTTPS"
|
|
if appops_ok
|
|
else "AppOps endpoint must be private-IP HTTP or HTTPS",
|
|
)
|
|
return valid
|
|
|
|
|
|
def _coherence_checks(environment: dict[str, str], results: list[dict[str, str]]) -> bool:
|
|
issuer = urlparse(environment.get("HUB_OIDC_ISSUER", ""))
|
|
redirect = urlparse(environment.get("HUB_OIDC_REDIRECT_URI", ""))
|
|
public_mcp = urlparse(environment.get("HUB_PUBLIC_MCP_URL", ""))
|
|
role_map = _decode_json(environment.get("HUB_OIDC_ROLE_GROUP_MAP", ""), dict) or {}
|
|
allowed_groups = {
|
|
item.strip() for item in environment.get("AUTHENTIK_ALLOWED_GROUPS", "").split(",")
|
|
}
|
|
scopes = {item.strip() for item in environment.get("AUTHENTIK_SCOPES", "").split(",")}
|
|
additional_redirect = urlparse(environment.get("HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI", ""))
|
|
additional_issuer = urlparse(environment.get("HUB_MCP_ADDITIONAL_OIDC_ISSUER", ""))
|
|
auth_ok = (
|
|
redirect.hostname == environment.get("DNS_HUB_HOSTNAME")
|
|
and redirect.path == "/auth/callback"
|
|
and set(role_map.values()) <= {"viewer", "operator", "admin"}
|
|
and set(role_map) <= allowed_groups
|
|
and {"openid", "profile", "email", "groups", "offline_access"} <= scopes
|
|
and additional_issuer.scheme == "https"
|
|
and additional_issuer.hostname == issuer.hostname
|
|
and additional_issuer.path == "/application/o/chatgpt-itworx-gitea/"
|
|
and not additional_issuer.query
|
|
and not additional_issuer.fragment
|
|
and additional_redirect.scheme == "https"
|
|
and additional_redirect.hostname == "chatgpt.com"
|
|
and bool(re.fullmatch(r"/connector/oauth/[A-Za-z0-9_-]+", additional_redirect.path))
|
|
and not additional_redirect.query
|
|
and not additional_redirect.fragment
|
|
and environment.get("HUB_MCP_ADDITIONAL_OIDC_AUDIENCE")
|
|
== environment.get("HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID")
|
|
and environment.get("NPM_HUB_PROXY_HOST") == environment.get("DNS_HUB_HOSTNAME")
|
|
and environment.get("NPM_MCP_PROXY_HOST") == environment.get("DNS_MCP_HOSTNAME")
|
|
and public_mcp.scheme == "https"
|
|
and public_mcp.hostname == environment.get("DNS_MCP_HOSTNAME")
|
|
and not public_mcp.username
|
|
and not public_mcp.password
|
|
and public_mcp.path in {"", "/"}
|
|
)
|
|
valid = _check(
|
|
results,
|
|
"auth.coherence",
|
|
auth_ok,
|
|
"issuer/client/audience/redirect/scopes/groups are internally coherent"
|
|
if auth_ok
|
|
else "Authentik, redirect, scope, group, NPM, or DNS inputs conflict",
|
|
)
|
|
single_host_ok = environment.get("DNS_HUB_HOSTNAME") == environment.get(
|
|
"DNS_MCP_HOSTNAME"
|
|
) and environment.get("NPM_HUB_PROXY_HOST") == environment.get("NPM_MCP_PROXY_HOST")
|
|
valid &= _check(
|
|
results,
|
|
"auth.single_host",
|
|
single_host_ok,
|
|
"management and MCP dataplane share the single approved public hostname"
|
|
if single_host_ok
|
|
else "DNS_HUB_HOSTNAME/NPM_HUB_PROXY_HOST must equal DNS_MCP_HOSTNAME/NPM_MCP_PROXY_HOST "
|
|
"-- a distinct hub.example.com-style hostname is not supported",
|
|
)
|
|
cf_ok = (
|
|
environment.get("CONTEXTFORGE_DB_HOST") == "postgres"
|
|
and environment.get("CONTEXTFORGE_DB_NAME") == "contextforge"
|
|
and environment.get("CONTEXTFORGE_DB_USER") == "contextforge"
|
|
and environment.get("REDIS_AUTH_ENABLED") == "true"
|
|
)
|
|
valid &= _check(
|
|
results,
|
|
"contextforge.coherence",
|
|
cf_ok,
|
|
"ContextForge database and Redis-auth contracts are complete"
|
|
if cf_ok
|
|
else "ContextForge database settings or production Redis authentication are invalid",
|
|
)
|
|
try:
|
|
rate_limit = int(environment.get("HUB_RATE_LIMIT_PER_MINUTE", ""))
|
|
except ValueError:
|
|
rate_limit = 0
|
|
valid &= _check(
|
|
results,
|
|
"dataplane.rate-limit",
|
|
1 <= rate_limit <= 10_000,
|
|
"dataplane rate limit is a bounded positive integer"
|
|
if 1 <= rate_limit <= 10_000
|
|
else "dataplane rate limit is missing or outside 1..10000",
|
|
)
|
|
try:
|
|
upstream_concurrency = int(environment.get("HUB_UPSTREAM_MAX_CONCURRENCY", ""))
|
|
queue_timeout = float(environment.get("HUB_UPSTREAM_QUEUE_TIMEOUT_SECONDS", ""))
|
|
except ValueError:
|
|
upstream_concurrency = 0
|
|
queue_timeout = 0
|
|
admission_ok = 1 <= upstream_concurrency <= 256 and 0.1 <= queue_timeout <= 30
|
|
valid &= _check(
|
|
results,
|
|
"dataplane.admission-control",
|
|
admission_ok,
|
|
"dataplane concurrency and queue timeout are bounded"
|
|
if admission_ok
|
|
else "dataplane concurrency must be 1..256 and queue timeout 0.1..30 seconds",
|
|
)
|
|
repositories = _decode_json(environment.get("GITEA_REPOSITORIES_JSON", ""), list) or []
|
|
discovery_project = environment.get("GITEA_AUTO_DISCOVER_PROJECT", "").strip().lower()
|
|
gitea_ok = all(
|
|
isinstance(item, dict)
|
|
and all(
|
|
isinstance(item.get(key), str) and item[key].strip()
|
|
for key in ("project", "owner", "name")
|
|
)
|
|
for item in repositories
|
|
)
|
|
token_scopes = {
|
|
item.strip().lower() for item in environment.get("GITEA_TOKEN_SCOPE", "").split(",")
|
|
}
|
|
write_token_scopes = {
|
|
item.strip().lower() for item in environment.get("GITEA_WRITE_TOKEN_SCOPE", "").split(",")
|
|
}
|
|
gitea_ok = (
|
|
gitea_ok
|
|
and bool(token_scopes)
|
|
and not any("write" in scope or "admin" in scope for scope in token_scopes)
|
|
and write_token_scopes == {"write:repository", "write:issue"}
|
|
and environment.get("GITEA_ENABLE_PR_WRITES", "").lower() == "true"
|
|
and bool(discovery_project)
|
|
and discovery_project
|
|
in {str(item.get("project", "")).strip().lower() for item in repositories}
|
|
)
|
|
valid &= _check(
|
|
results,
|
|
"gitea.configuration",
|
|
bool(repositories) and gitea_ok,
|
|
"Gitea repository bootstrap, account discovery and separated read/write scopes are complete"
|
|
if repositories and gitea_ok
|
|
else "Gitea repository bootstrap or account-discovery project is malformed",
|
|
)
|
|
bindings = _decode_json(environment.get("MCP_CLIENT_BINDINGS_JSON", ""), list) or []
|
|
binding_keys: set[tuple[str, str, str]] = set()
|
|
bindings_ok = bool(bindings)
|
|
for item in bindings:
|
|
if not isinstance(item, dict):
|
|
bindings_ok = False
|
|
continue
|
|
try:
|
|
client_id = str(UUID(str(item.get("clientId", ""))))
|
|
except ValueError:
|
|
bindings_ok = False
|
|
continue
|
|
subject = item.get("subject")
|
|
project = item.get("project")
|
|
target_environment = item.get("environment")
|
|
key = (client_id, str(project), str(target_environment))
|
|
entry_ok = (
|
|
isinstance(subject, str)
|
|
and bool(subject.strip())
|
|
and isinstance(project, str)
|
|
and bool(re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", project))
|
|
and target_environment in {"dev", "test", "staging", "prod"}
|
|
and isinstance(item.get("enabled", True), bool)
|
|
and key not in binding_keys
|
|
)
|
|
bindings_ok &= entry_ok
|
|
binding_keys.add(key)
|
|
valid &= _check(
|
|
results,
|
|
"auth.machine_bindings",
|
|
bindings_ok,
|
|
"machine subjects have unique explicit project/environment bindings"
|
|
if bindings_ok
|
|
else "machine binding inventory is malformed, empty, or ambiguous",
|
|
)
|
|
return valid
|
|
|
|
|
|
def _oidc_discovery_checks(
|
|
environment: dict[str, str], results: list[dict[str, str]], enabled: bool
|
|
) -> bool:
|
|
if not enabled:
|
|
_defer(
|
|
results,
|
|
"oidc.discovery",
|
|
"enable --verify-oidc-discovery to validate live refresh capabilities",
|
|
)
|
|
return True
|
|
|
|
valid = True
|
|
issuers = {
|
|
"hub": environment.get("HUB_OIDC_ISSUER", "").strip(),
|
|
"chatgpt": environment.get("HUB_MCP_ADDITIONAL_OIDC_ISSUER", "").strip(),
|
|
}
|
|
for name, issuer in issuers.items():
|
|
payload: dict[str, Any] = {}
|
|
try:
|
|
if urlparse(issuer).scheme != "https":
|
|
raise ValueError("OIDC issuer must use HTTPS")
|
|
discovery_url = issuer.rstrip("/") + "/.well-known/openid-configuration"
|
|
request = Request( # noqa: S310
|
|
discovery_url, headers={"Accept": "application/json"}
|
|
)
|
|
with _OIDC_DISCOVERY_OPENER.open(request, timeout=5) as response: # noqa: S310
|
|
body = response.read(65_537)
|
|
if (
|
|
response.status != 200
|
|
or response.geturl() != discovery_url
|
|
or len(body) > 65_536
|
|
):
|
|
raise ValueError("OIDC discovery response is invalid or oversized")
|
|
decoded = json.loads(body)
|
|
if isinstance(decoded, dict):
|
|
payload = decoded
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
payload = {}
|
|
|
|
scopes = set(payload.get("scopes_supported", []))
|
|
grants = set(payload.get("grant_types_supported", []))
|
|
pkce = set(payload.get("code_challenge_methods_supported", []))
|
|
authorization = urlparse(str(payload.get("authorization_endpoint", "")))
|
|
token = urlparse(str(payload.get("token_endpoint", "")))
|
|
ok = (
|
|
payload.get("issuer") == issuer
|
|
and {"openid", "offline_access"} <= scopes
|
|
and {"authorization_code", "refresh_token"} <= grants
|
|
and "S256" in pkce
|
|
and authorization.scheme == "https"
|
|
and token.scheme == "https"
|
|
and authorization.hostname == token.hostname == urlparse(issuer).hostname
|
|
)
|
|
valid &= _check(
|
|
results,
|
|
f"oidc.{name}.discovery",
|
|
ok,
|
|
"issuer, endpoints, PKCE, offline_access and refresh grant are coherent"
|
|
if ok
|
|
else "live OIDC discovery lacks a required refresh capability",
|
|
)
|
|
return valid
|
|
|
|
|
|
def _docker_network_checks(
|
|
environment: dict[str, str], results: list[dict[str, str]], enabled: bool
|
|
) -> bool:
|
|
if not enabled:
|
|
_defer(results, "network.live", "enable --verify-docker-networks on the intended target")
|
|
return True
|
|
docker = shutil.which("docker")
|
|
valid = bool(docker)
|
|
for network in [*OWNED_NETWORKS, environment.get("NPM_NETWORK_NAME", "")]:
|
|
completed = subprocess.run( # noqa: S603
|
|
[docker or "docker", "network", "inspect", network],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
valid &= _check(
|
|
results,
|
|
f"network.{network}",
|
|
completed.returncode == 0,
|
|
"Docker network exists"
|
|
if completed.returncode == 0
|
|
else "Docker network is unavailable",
|
|
)
|
|
return valid
|
|
|
|
|
|
def _target_checks(
|
|
environment: dict[str, str], results: list[dict[str, str]], enabled: bool
|
|
) -> bool:
|
|
if not enabled:
|
|
_defer(
|
|
results, "connectivity.live", "enable --verify-network-targets to open target sockets"
|
|
)
|
|
return True
|
|
targets = _decode_json(environment.get("NETWORK_TARGETS_JSON", ""), dict) or {}
|
|
valid = True
|
|
for name, target in sorted(targets.items()):
|
|
reachable = False
|
|
if isinstance(target, str) and ":" in target:
|
|
host, port_text = target.rsplit(":", 1)
|
|
try:
|
|
with socket.create_connection((host, int(port_text)), timeout=3):
|
|
reachable = True
|
|
except (OSError, ValueError):
|
|
pass
|
|
valid &= _check(
|
|
results,
|
|
f"connectivity.{name}",
|
|
reachable,
|
|
"TCP target is reachable" if reachable else "TCP target is unavailable",
|
|
)
|
|
return valid
|
|
|
|
|
|
def _dns_checks(environment: dict[str, str], results: list[dict[str, str]], enabled: bool) -> bool:
|
|
if not enabled:
|
|
_defer(results, "dns.live", "enable --verify-dns to query DNS")
|
|
return True
|
|
expected = _decode_json(environment.get("DNS_EXPECTED_ADDRESSES_JSON", ""), dict) or {}
|
|
valid = True
|
|
for variable in ("DNS_HUB_HOSTNAME", "DNS_MCP_HOSTNAME"):
|
|
hostname = environment.get(variable, "")
|
|
addresses = set(expected.get(hostname, []))
|
|
try:
|
|
resolved = {item[4][0] for item in socket.getaddrinfo(hostname, 443)}
|
|
except OSError:
|
|
resolved = set()
|
|
ok = bool(addresses & resolved)
|
|
valid &= _check(
|
|
results,
|
|
f"dns.{hostname or variable.lower()}",
|
|
ok,
|
|
"DNS matches an expected address" if ok else "DNS does not match expected addresses",
|
|
)
|
|
return valid
|
|
|
|
|
|
def _bind_port_checks(
|
|
environment: dict[str, str],
|
|
topology: dict[str, Any],
|
|
results: list[dict[str, str]],
|
|
enabled: bool,
|
|
phase: str,
|
|
) -> bool:
|
|
if not enabled:
|
|
_defer(results, "ports.live", "enable --verify-bind-ports on the intended target")
|
|
return True
|
|
valid = True
|
|
services = {"HUB": "web", "MCP": "dataplane-router"}
|
|
docker = shutil.which("docker") if phase == "poststart" else None
|
|
for prefix, service_name in services.items():
|
|
bind_ip = environment.get(f"{prefix}_BIND_IP", "")
|
|
try:
|
|
host_port = int(environment[f"{prefix}_PORT"])
|
|
except (KeyError, ValueError):
|
|
host_port = 0
|
|
available = False
|
|
if phase == "predeploy":
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
probe.bind((bind_ip, host_port))
|
|
available = True
|
|
except (OSError, ValueError):
|
|
pass
|
|
else:
|
|
published = topology.get("services", {}).get(service_name, {}).get("ports", [])
|
|
target_ports = []
|
|
for item in published:
|
|
parts = str(item).rsplit(":", 2)
|
|
if len(parts) == 3 and parts[0] == bind_ip and parts[1] == str(host_port):
|
|
target_ports.append(parts[2])
|
|
if docker and len(target_ports) == 1:
|
|
listed = subprocess.run( # noqa: S603
|
|
[
|
|
docker,
|
|
"ps",
|
|
"--filter",
|
|
"label=com.docker.compose.project=itworx-mcp-hub",
|
|
"--filter",
|
|
f"label=com.docker.compose.service={service_name}",
|
|
"--format",
|
|
"{{.ID}}",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
container_ids = listed.stdout.splitlines() if listed.returncode == 0 else []
|
|
if len(container_ids) == 1:
|
|
inspected = subprocess.run( # noqa: S603
|
|
[
|
|
docker,
|
|
"inspect",
|
|
"--format",
|
|
"{{json .NetworkSettings.Ports}}",
|
|
container_ids[0],
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
try:
|
|
bindings = json.loads(inspected.stdout) if inspected.returncode == 0 else {}
|
|
except json.JSONDecodeError:
|
|
bindings = {}
|
|
expected = {"HostIp": bind_ip, "HostPort": str(host_port)}
|
|
available = bindings == {f"{target_ports[0]}/tcp": [expected]}
|
|
valid &= _check(
|
|
results,
|
|
f"ports.{prefix.lower()}",
|
|
available,
|
|
(
|
|
"host binding is available"
|
|
if phase == "predeploy" and available
|
|
else "expected project service owns the exact host binding"
|
|
if available
|
|
else "host binding conflicts or is invalid"
|
|
if phase == "predeploy"
|
|
else "expected project service does not own the exact host binding"
|
|
),
|
|
)
|
|
return valid
|
|
|
|
|
|
def redacted_topology(topology: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return only deployable structure; omit values and host paths."""
|
|
|
|
services: dict[str, Any] = {}
|
|
for name, service in sorted(topology.get("services", {}).items()):
|
|
services[name] = {
|
|
"image": service.get("image"),
|
|
"networks": service.get("networks", []),
|
|
"ports": ["[REDACTED-HOST-BINDING]"] if service.get("ports") else [],
|
|
"secrets": sorted(_service_secret_names(service)),
|
|
"environmentKeys": sorted(service.get("environment", {})),
|
|
"readOnly": service.get("read_only", False),
|
|
"capDrop": service.get("cap_drop", []),
|
|
}
|
|
return {
|
|
"name": topology.get("name"),
|
|
"services": services,
|
|
"networks": sorted(topology.get("networks", {})),
|
|
"secrets": sorted(topology.get("secrets", {})),
|
|
}
|
|
|
|
|
|
def run(
|
|
environment: dict[str, str],
|
|
phase: str,
|
|
verify_registry: bool = False,
|
|
*,
|
|
verify_docker_networks: bool = False,
|
|
verify_network_targets: bool = False,
|
|
verify_dns: bool = False,
|
|
verify_bind_ports: bool = False,
|
|
verify_oidc_discovery: bool = False,
|
|
topology_override: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
results: list[dict[str, str]] = []
|
|
required = _required_values(environment, results)
|
|
if not required:
|
|
return {"phase": phase, "passed": False, "checks": results, "failureCount": 1}
|
|
structured = _structured_input_checks(environment, results)
|
|
paths, secret_values = _path_checks(environment, results, phase)
|
|
try:
|
|
topology = topology_override or _rendered_topology(environment)
|
|
readable = True
|
|
except (OSError, yaml.YAMLError):
|
|
topology = {}
|
|
readable = False
|
|
_check(
|
|
results,
|
|
"compose.readable",
|
|
readable,
|
|
"production topology rendered" if readable else "Compose files are invalid",
|
|
)
|
|
mapping = _mapping_checks(topology, results) if readable else False
|
|
images = _image_checks(environment, topology, results, verify_registry) if readable else False
|
|
topology_ok = _topology_checks(topology, secret_values, results) if readable else False
|
|
urls = _url_checks(environment, results)
|
|
coherence = _coherence_checks(environment, results)
|
|
oidc_discovery = _oidc_discovery_checks(environment, results, verify_oidc_discovery)
|
|
networks = _docker_network_checks(environment, results, verify_docker_networks)
|
|
targets = _target_checks(environment, results, verify_network_targets)
|
|
dns = _dns_checks(environment, results, verify_dns)
|
|
bind_ports = _bind_port_checks(environment, topology, results, verify_bind_ports, phase)
|
|
passed = all(
|
|
(
|
|
required,
|
|
structured,
|
|
paths,
|
|
readable,
|
|
mapping,
|
|
images,
|
|
topology_ok,
|
|
urls,
|
|
coherence,
|
|
oidc_discovery,
|
|
networks,
|
|
targets,
|
|
dns,
|
|
bind_ports,
|
|
)
|
|
)
|
|
return {
|
|
"phase": phase,
|
|
"passed": passed,
|
|
"checks": results,
|
|
"failureCount": sum(item["status"] == "fail" for item in results),
|
|
"deferredCount": sum(item["status"] == "deferred" for item in results),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--env-file", type=Path, required=True)
|
|
parser.add_argument("--phase", choices=("predeploy", "poststart"), default="predeploy")
|
|
parser.add_argument("--verify-registry", action="store_true")
|
|
parser.add_argument("--verify-docker-networks", action="store_true")
|
|
parser.add_argument("--verify-network-targets", action="store_true")
|
|
parser.add_argument("--verify-dns", action="store_true")
|
|
parser.add_argument("--verify-bind-ports", action="store_true")
|
|
parser.add_argument("--verify-oidc-discovery", action="store_true")
|
|
parser.add_argument("--redacted-topology-output", type=Path)
|
|
args = parser.parse_args()
|
|
try:
|
|
environment = dict(os.environ)
|
|
environment.update(load_env_file(args.env_file))
|
|
result = run(
|
|
environment,
|
|
args.phase,
|
|
args.verify_registry,
|
|
verify_docker_networks=args.verify_docker_networks,
|
|
verify_network_targets=args.verify_network_targets,
|
|
verify_dns=args.verify_dns,
|
|
verify_bind_ports=args.verify_bind_ports,
|
|
verify_oidc_discovery=args.verify_oidc_discovery,
|
|
)
|
|
if args.redacted_topology_output and result["passed"]:
|
|
output = redacted_topology(_rendered_topology(environment))
|
|
args.redacted_topology_output.write_text(
|
|
json.dumps(output, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
)
|
|
except (OSError, ValueError) as exc:
|
|
result = {
|
|
"passed": False,
|
|
"failureCount": 1,
|
|
"checks": [{"id": "inputs.env-file", "status": "fail", "detail": str(exc)}],
|
|
}
|
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
return 0 if result["passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|