Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11814
-288
No files matched your search
@@ -6,11 +6,13 @@ for command execution, Compose upload, arbitrary host paths or raw secrets.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..core.security import require_operator_request
|
||||
from ..db.session import get_session
|
||||
@@ -22,6 +24,7 @@ from ..schemas.forge_secret_delivery import (
|
||||
ForgeSecretDeliveryResultV1,
|
||||
)
|
||||
from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessResultV1
|
||||
from ..schemas.forge_route_stage import ForgeRouteStageResultV1
|
||||
from ..services.deployment_broker import DeploymentBrokerClient
|
||||
from ..services.deployment_control_plane import (
|
||||
DeploymentControlError,
|
||||
@@ -56,6 +59,27 @@ class WorkMeshPromotionPreflightRequest(BaseModel):
|
||||
DeploymentSession = Annotated[Session, Depends(get_session)]
|
||||
DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")]
|
||||
DeploymentLimit = Annotated[int, Query(ge=1, le=250)]
|
||||
_FORGE_ROUTE_JOB_ID = re.compile(r"^forge-install-[0-9a-f]{64}$")
|
||||
_FORGE_ROUTE_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
|
||||
|
||||
async def _require_empty_forge_route_request(request: Request) -> None:
|
||||
lengths = request.headers.getlist("content-length")
|
||||
if (
|
||||
request.query_params
|
||||
or len(lengths) > 1
|
||||
or (lengths and lengths[0] != "0")
|
||||
or request.headers.getlist("transfer-encoding")
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="invalid_forge_route_request")
|
||||
try:
|
||||
async for chunk in request.stream():
|
||||
if chunk:
|
||||
raise HTTPException(status_code=400, detail="invalid_forge_route_request")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - transport detail is not part of this contract.
|
||||
raise HTTPException(status_code=400, detail="invalid_forge_route_request") from None
|
||||
|
||||
|
||||
def _idempotency_header(value: str | None) -> str:
|
||||
@@ -232,6 +256,70 @@ def get_forge_first_install_readiness(
|
||||
return ForgeBootstrapApplyService().current_readiness(session, job_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/forge-bootstrap/first-install/{job_id}/route",
|
||||
response_model=ForgeRouteStageResultV1,
|
||||
description=(
|
||||
"Stages only the fixed Forge NPM host in a disabled state after fresh runtime and network checks. "
|
||||
"The request body must be empty; this operation does not issue certificates or activate routing."
|
||||
),
|
||||
)
|
||||
async def stage_forge_first_install_route(
|
||||
job_id: str,
|
||||
request: Request,
|
||||
actor: Annotated[str, Depends(_require_deployment_operator)],
|
||||
session: DeploymentSession,
|
||||
):
|
||||
if _FORGE_ROUTE_JOB_ID.fullmatch(job_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Forge first-install request not found.")
|
||||
await _require_empty_forge_route_request(request)
|
||||
keys = request.headers.getlist("idempotency-key")
|
||||
if len(keys) != 1 or _FORGE_ROUTE_IDEMPOTENCY_KEY.fullmatch(keys[0]) is None:
|
||||
raise HTTPException(status_code=400, detail="invalid_forge_route_idempotency_key")
|
||||
from ..services.forge_route_stage import stage_forge_route
|
||||
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
stage_forge_route,
|
||||
session,
|
||||
job_id=job_id,
|
||||
idempotency_key=keys[0],
|
||||
actor_identity=actor,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - no controller/NPM details may reach the caller.
|
||||
raise HTTPException(status_code=503, detail="forge_route_operation_unavailable") from None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/forge-bootstrap/first-install/{job_id}/route",
|
||||
response_model=ForgeRouteStageResultV1,
|
||||
description=(
|
||||
"Reads and reconciles the fixed disabled Forge host using NPM GET requests only. "
|
||||
"It never retries a host creation."
|
||||
),
|
||||
)
|
||||
async def get_forge_first_install_route(
|
||||
job_id: str,
|
||||
request: Request,
|
||||
actor_identity: Annotated[str, Depends(_require_deployment_operator)],
|
||||
session: DeploymentSession,
|
||||
):
|
||||
if _FORGE_ROUTE_JOB_ID.fullmatch(job_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Forge first-install request not found.")
|
||||
await _require_empty_forge_route_request(request)
|
||||
from ..services.forge_route_stage import reconcile_forge_route
|
||||
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
reconcile_forge_route,
|
||||
session,
|
||||
job_id=job_id,
|
||||
actor_identity=actor_identity,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - no controller/NPM details may reach the caller.
|
||||
raise HTTPException(status_code=503, detail="forge_route_reconcile_unavailable") from None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100):
|
||||
return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -23,12 +25,172 @@ class _NpmConfig:
|
||||
timeout_seconds: float
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
class _NpmForgeRoutePermissionDenied(RuntimeError):
|
||||
"""Fixed marker for a failed route-only permission preflight."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("npm_forge_route_permission_denied")
|
||||
|
||||
|
||||
class _ForgeRouteNpmSession:
|
||||
"""Route-only transport pinned to one verified NPM endpoint/config snapshot."""
|
||||
|
||||
def __init__(self, connector: NginxProxyManagerConnector, config: _NpmConfig):
|
||||
self._connector = connector
|
||||
self._config_snapshot = config
|
||||
self._token: str | None = None
|
||||
self.verified_tls = config.verify_tls is True
|
||||
self.endpoint_sha256 = _npm_endpoint_sha256(config.url)
|
||||
|
||||
def _assert_current(self) -> None:
|
||||
try:
|
||||
current = self._connector._config()
|
||||
except Exception: # noqa: BLE001 - never surface config diagnostics.
|
||||
current = None
|
||||
if current != self._config_snapshot:
|
||||
raise RuntimeError("npm_configuration_changed") from None
|
||||
|
||||
def _login(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
self._assert_current()
|
||||
config = self._config_snapshot
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=True,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = client.post(
|
||||
f"{config.url}/api/tokens",
|
||||
json={"identity": config.identity, "secret": config.secret},
|
||||
headers={"Accept": "application/json"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
except (httpx.RequestError, ValueError):
|
||||
raise RuntimeError("npm_request_failed") from None
|
||||
if type(response.status_code) is not int or response.status_code != 200:
|
||||
raise RuntimeError("npm_login_failed") from None
|
||||
try:
|
||||
payload = response.json()
|
||||
except (ValueError, TypeError):
|
||||
raise RuntimeError("npm_invalid_response") from None
|
||||
token = payload.get("token") if isinstance(payload, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
raise RuntimeError("npm_login_failed") from None
|
||||
self._assert_current()
|
||||
self._token = token
|
||||
return token
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
if (method, path) not in {
|
||||
("GET", "/users/me"),
|
||||
("GET", "/nginx/proxy-hosts"),
|
||||
("POST", "/nginx/proxy-hosts"),
|
||||
}:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
if path == "/users/me":
|
||||
if kwargs != {"params": {"expand": "permissions"}}:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
elif method == "GET":
|
||||
if kwargs:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
elif set(kwargs) != {"json"} or type(kwargs.get("json")) is not dict:
|
||||
raise RuntimeError("npm_forge_route_request_invalid") from None
|
||||
self._assert_current()
|
||||
token = self._login()
|
||||
self._assert_current()
|
||||
config = self._config_snapshot
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=True,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = client.request(
|
||||
method,
|
||||
f"{config.url}/api{path}",
|
||||
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||||
follow_redirects=False,
|
||||
**kwargs,
|
||||
)
|
||||
except (httpx.RequestError, ValueError):
|
||||
raise RuntimeError("npm_request_failed") from None
|
||||
if type(response.status_code) is not int or not (
|
||||
response.status_code == 200 if method == "GET" else response.status_code in {200, 201}
|
||||
):
|
||||
raise RuntimeError("npm_api_error") from None
|
||||
try:
|
||||
return response.json()
|
||||
except (ValueError, TypeError):
|
||||
raise RuntimeError("npm_invalid_response") from None
|
||||
|
||||
def _has_forge_route_permissions(self) -> bool:
|
||||
try:
|
||||
user = self._request("GET", "/users/me", params={"expand": "permissions"})
|
||||
except RuntimeError as exc:
|
||||
if str(exc) == "npm_configuration_changed":
|
||||
raise
|
||||
return False
|
||||
except Exception: # noqa: BLE001 - permission response details stay private.
|
||||
return False
|
||||
if type(user) is not dict:
|
||||
return False
|
||||
user_id = user.get("id")
|
||||
disabled = user.get("is_disabled")
|
||||
permissions = user.get("permissions")
|
||||
return bool(
|
||||
type(user_id) is int
|
||||
and user_id > 0
|
||||
and type(disabled) is bool
|
||||
and disabled is False
|
||||
and type(permissions) is dict
|
||||
and type(permissions.get("visibility")) is str
|
||||
and permissions["visibility"] == "all"
|
||||
and type(permissions.get("proxy_hosts")) is str
|
||||
and permissions["proxy_hosts"] == "manage"
|
||||
)
|
||||
|
||||
def _require_forge_route_permissions(self) -> None:
|
||||
if not self._has_forge_route_permissions():
|
||||
raise _NpmForgeRoutePermissionDenied() from None
|
||||
|
||||
def list_forge_route_hosts(self) -> list[dict[str, Any]]:
|
||||
self._require_forge_route_permissions()
|
||||
payload = self._request("GET", "/nginx/proxy-hosts")
|
||||
return self._connector._parse_forge_route_hosts(payload)
|
||||
|
||||
def create_forge_disabled_proxy_host(self, ownership_id: str) -> dict[str, Any]:
|
||||
if not re.fullmatch(r"froute-[0-9a-f]{32}", ownership_id):
|
||||
raise RuntimeError("npm_forge_route_ownership_invalid") from None
|
||||
self._require_forge_route_permissions()
|
||||
payload = self._request(
|
||||
"POST",
|
||||
"/nginx/proxy-hosts",
|
||||
json={
|
||||
"domain_names": ["forge.example.com"],
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "itworx-forge",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": 0,
|
||||
"certificate_id": 0,
|
||||
"enabled": False,
|
||||
"ssl_forced": False,
|
||||
"http2_support": False,
|
||||
"hsts_enabled": False,
|
||||
"hsts_subdomains": False,
|
||||
"caching_enabled": False,
|
||||
"block_exploits": True,
|
||||
"allow_websocket_upgrade": False,
|
||||
"trust_forwarded_proto": False,
|
||||
"locations": [],
|
||||
"meta": {"appops_forge_route_id": ownership_id},
|
||||
"advanced_config": "",
|
||||
},
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("npm_invalid_response") from None
|
||||
return payload
|
||||
|
||||
|
||||
def _as_float(value: Any, default: float) -> float:
|
||||
@@ -54,15 +216,95 @@ def _as_list(value: Any) -> list[Any]:
|
||||
|
||||
|
||||
def _safe_url(value: Any = None) -> str:
|
||||
raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/')
|
||||
if not raw:
|
||||
raw = value if value is not None else settings.apis.get('NPM_URL')
|
||||
try:
|
||||
url = _validated_npm_url(raw)
|
||||
_verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS'))
|
||||
return url
|
||||
except RuntimeError:
|
||||
return ''
|
||||
parsed = urlparse(raw)
|
||||
if not parsed.username and not parsed.password:
|
||||
return raw
|
||||
host = parsed.hostname or ''
|
||||
port = f':{parsed.port}' if parsed.port else ''
|
||||
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/')
|
||||
|
||||
|
||||
def _validated_npm_url(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > 2048:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if value != value.strip() or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if '\\' in value or '?' in value or '#' in value:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
raise RuntimeError('npm_url_invalid') from None
|
||||
if parsed.scheme.lower() == 'http':
|
||||
raise RuntimeError('npm_https_required')
|
||||
if parsed.scheme.lower() != 'https' or not parsed.netloc:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if '@' in parsed.netloc or '%' in parsed.netloc or parsed.username is not None or parsed.password is not None:
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
if parsed.netloc.endswith(':'):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname or hostname.endswith('.') or (port is not None and not 1 <= port <= 65535):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
try:
|
||||
host_ascii = hostname.encode('idna').decode('ascii').lower()
|
||||
except UnicodeError:
|
||||
raise RuntimeError('npm_url_invalid') from None
|
||||
if ':' in host_ascii:
|
||||
try:
|
||||
ipaddress.IPv6Address(host_ascii)
|
||||
except ValueError:
|
||||
raise RuntimeError('npm_url_invalid') from None
|
||||
authority_host = f'[{host_ascii}]'
|
||||
else:
|
||||
if len(host_ascii) > 253 or any(
|
||||
not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label)
|
||||
for label in host_ascii.split('.')
|
||||
):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
authority_host = host_ascii
|
||||
authority = authority_host + (f':{port}' if port is not None else '')
|
||||
|
||||
path = parsed.path
|
||||
if path and (
|
||||
not path.startswith('/')
|
||||
or not re.fullmatch(r"/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*(?:/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*)*", path)
|
||||
or re.search(r'%(?![0-9A-Fa-f]{2})', path)
|
||||
):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
decoded_path = unquote(path)
|
||||
if '\\' in decoded_path or any(segment in {'.', '..'} for segment in decoded_path.split('/')):
|
||||
raise RuntimeError('npm_url_invalid')
|
||||
return f'https://{authority}{path}'.rstrip('/')
|
||||
|
||||
|
||||
def _npm_endpoint_sha256(value: str) -> str:
|
||||
"""Hash the normalized HTTPS API base without persisting its URL."""
|
||||
normalized = _validated_npm_url(value)
|
||||
return hashlib.sha256(normalized.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
def _verified_tls_setting(value: Any) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
enabled = value
|
||||
elif isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {'1', 'true', 'yes', 'on'}:
|
||||
enabled = True
|
||||
elif normalized in {'0', 'false', 'no', 'off'}:
|
||||
enabled = False
|
||||
else:
|
||||
raise RuntimeError('npm_config_invalid')
|
||||
else:
|
||||
raise RuntimeError('npm_config_invalid') # noqa: TRY004 - use a fixed public connector reason
|
||||
if not enabled:
|
||||
raise RuntimeError('npm_tls_verification_required')
|
||||
return True
|
||||
|
||||
|
||||
def _parse_date(value: Any) -> datetime | None:
|
||||
@@ -111,57 +353,107 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
super().__init__()
|
||||
self._runtime_token: str | None = None
|
||||
self._read_cache: dict[str, dict[str, Any]] = {}
|
||||
self._credential_binding: tuple[str, str, str, bool] | None = None
|
||||
|
||||
def _clear_runtime_context(self) -> None:
|
||||
self._runtime_token = None
|
||||
self._credential_binding = None
|
||||
self._read_cache.clear()
|
||||
|
||||
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
|
||||
try:
|
||||
config = self._config()
|
||||
except RuntimeError as exc:
|
||||
return self._error_payload(exc, items=[], total=0)
|
||||
if config is None:
|
||||
return factory()
|
||||
if key not in self._read_cache:
|
||||
self._read_cache[key] = factory()
|
||||
return self._read_cache[key]
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET'))
|
||||
configured = bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET'))
|
||||
if not configured:
|
||||
self._clear_runtime_context()
|
||||
return configured
|
||||
|
||||
def _config(self) -> _NpmConfig | None:
|
||||
if not self.is_configured():
|
||||
self._clear_runtime_context()
|
||||
return None
|
||||
try:
|
||||
url = _validated_npm_url(settings.apis.get('NPM_URL'))
|
||||
verify_tls = _verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS'))
|
||||
identity = settings.apis.get('NPM_IDENTITY')
|
||||
secret = settings.apis.get('NPM_SECRET')
|
||||
if not isinstance(identity, str) or not identity.strip() or not isinstance(secret, str) or not secret:
|
||||
raise RuntimeError('missing_config')
|
||||
config = _NpmConfig(
|
||||
url=url,
|
||||
identity=identity,
|
||||
secret=secret,
|
||||
verify_tls=verify_tls,
|
||||
timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0),
|
||||
)
|
||||
except RuntimeError:
|
||||
self._clear_runtime_context()
|
||||
raise
|
||||
|
||||
binding = (config.url, config.identity, config.secret, config.verify_tls)
|
||||
if binding != self._credential_binding:
|
||||
self._runtime_token = None
|
||||
self._read_cache.clear()
|
||||
self._credential_binding = binding
|
||||
return config
|
||||
|
||||
def forge_route_session(self) -> _ForgeRouteNpmSession | None:
|
||||
"""Capture one strict endpoint/config for the entire Forge route operation."""
|
||||
try:
|
||||
config = self._config()
|
||||
if config is None or config.verify_tls is not True:
|
||||
return None
|
||||
if _validated_npm_url(config.url) != config.url:
|
||||
return None
|
||||
return _ForgeRouteNpmSession(self, config)
|
||||
except Exception: # noqa: BLE001 - never expose connector settings or diagnostics.
|
||||
return None
|
||||
return _NpmConfig(
|
||||
url=str(settings.apis['NPM_URL']).rstrip('/'),
|
||||
identity=str(settings.apis['NPM_IDENTITY']),
|
||||
secret=str(settings.apis['NPM_SECRET']),
|
||||
verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False),
|
||||
timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0),
|
||||
)
|
||||
|
||||
def _login(self) -> str:
|
||||
if self._runtime_token:
|
||||
return self._runtime_token
|
||||
config = self._config()
|
||||
if not config:
|
||||
raise RuntimeError('missing_config')
|
||||
if self._runtime_token:
|
||||
return self._runtime_token
|
||||
try:
|
||||
self.clear_error()
|
||||
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=config.verify_tls,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = client.post(
|
||||
f'{config.url}/api/tokens',
|
||||
json={'identity': config.identity, 'secret': config.secret},
|
||||
headers={'Accept': 'application/json'},
|
||||
follow_redirects=False,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
except httpx.RequestError:
|
||||
self.set_error('npm_request_failed')
|
||||
raise RuntimeError('npm_request_failed') from None
|
||||
if 300 <= response.status_code < 400:
|
||||
self.set_error('npm_redirect_denied')
|
||||
raise RuntimeError('npm_redirect_denied')
|
||||
if response.status_code in {400, 401, 403}:
|
||||
self.set_error('auth_failed')
|
||||
raise RuntimeError('auth_failed')
|
||||
if response.status_code >= 400:
|
||||
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
self.set_error('npm_api_error')
|
||||
raise RuntimeError('npm_api_error')
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
except ValueError:
|
||||
self.set_error('npm_invalid_response')
|
||||
raise RuntimeError('npm_invalid_response') from None
|
||||
token = payload.get('token') if isinstance(payload, dict) else None
|
||||
if not token:
|
||||
self.set_error('auth_failed')
|
||||
@@ -174,31 +466,39 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
if not config:
|
||||
raise RuntimeError('missing_config')
|
||||
token = self._login()
|
||||
if self._credential_binding != (config.url, config.identity, config.secret, config.verify_tls):
|
||||
self.set_error('npm_configuration_changed')
|
||||
raise RuntimeError('npm_configuration_changed')
|
||||
try:
|
||||
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
||||
with httpx.Client(
|
||||
timeout=config.timeout_seconds,
|
||||
verify=config.verify_tls,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
kwargs['follow_redirects'] = False
|
||||
response = client.request(
|
||||
method,
|
||||
f'{config.url}/api{path}',
|
||||
headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'},
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
except httpx.RequestError:
|
||||
self.set_error('npm_request_failed')
|
||||
raise RuntimeError('npm_request_failed') from None
|
||||
if 300 <= response.status_code < 400:
|
||||
self.set_error('npm_redirect_denied')
|
||||
raise RuntimeError('npm_redirect_denied')
|
||||
if response.status_code in {401, 403}:
|
||||
self.set_error('auth_failed')
|
||||
raise RuntimeError('auth_failed')
|
||||
if response.status_code >= 400:
|
||||
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
self.set_error('npm_api_error')
|
||||
raise RuntimeError('npm_api_error')
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
message = redact_message(str(exc))
|
||||
self.set_error(message)
|
||||
raise RuntimeError(message)
|
||||
except ValueError:
|
||||
self.set_error('npm_invalid_response')
|
||||
raise RuntimeError('npm_invalid_response') from None
|
||||
|
||||
def test_connection(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
@@ -215,6 +515,12 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
return {'status': 'unreachable', 'reason': reason}
|
||||
return {'status': 'connected', 'proxyHostCount': len(hosts)}
|
||||
|
||||
@staticmethod
|
||||
def _parse_forge_route_hosts(payload: Any) -> list[dict[str, Any]]:
|
||||
if type(payload) is not list or len(payload) > 1000 or any(type(item) is not dict for item in payload):
|
||||
raise RuntimeError('npm_forge_route_inventory_invalid') from None
|
||||
return payload
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
return self._missing(proxyHostCount=0, certificateCount=0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -136,6 +137,200 @@ class PortainerConnector(BaseConnector):
|
||||
|
||||
return {'status': 'ok', 'containers': containers}
|
||||
|
||||
def forge_route_network_readiness(self) -> dict[str, Any]:
|
||||
"""Check only the fixed NPM and Forge frontdoor network membership."""
|
||||
try:
|
||||
config = self._config()
|
||||
except Exception: # noqa: BLE001 - fixed projection never returns config diagnostics.
|
||||
config = None
|
||||
try:
|
||||
parsed_url = urlsplit(config.url) if config else None
|
||||
parsed_port = parsed_url.port if parsed_url else None
|
||||
base_url = _safe_portainer_base_url(config.url) if config else None
|
||||
except (TypeError, ValueError):
|
||||
parsed_url = None
|
||||
parsed_port = None
|
||||
base_url = None
|
||||
if (
|
||||
not config
|
||||
or not isinstance(config.url, str)
|
||||
or len(config.url) > 2048
|
||||
or config.url != config.url.strip()
|
||||
or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in config.url)
|
||||
or "\\" in config.url
|
||||
or "?" in config.url
|
||||
or "#" in config.url
|
||||
or settings.apis.get('PORTAINER_VERIFY_TLS', settings.verify_tls) is not True
|
||||
or config.verify_tls is not True
|
||||
or parsed_url is None
|
||||
or parsed_url.scheme != 'https'
|
||||
or not parsed_url.hostname
|
||||
or (parsed_port is not None and not 1 <= parsed_port <= 65535)
|
||||
or parsed_url.username is not None
|
||||
or parsed_url.password is not None
|
||||
or parsed_url.query
|
||||
or parsed_url.fragment
|
||||
or '@' in parsed_url.netloc
|
||||
or '%' in parsed_url.netloc
|
||||
or any(segment in {".", ".."} for segment in parsed_url.path.split("/"))
|
||||
or base_url != config.url
|
||||
or not isinstance(config.api_key, str)
|
||||
or not config.api_key
|
||||
or re.fullmatch(r'[1-9][0-9]{0,9}', config.endpoint_id) is None
|
||||
):
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=_PORTAINER_DEFAULT_TIMEOUT_SECONDS,
|
||||
verify=True,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = client.get(
|
||||
f'{config.url}/api/endpoints/{config.endpoint_id}/docker/containers/json?all=1',
|
||||
headers={'Accept': 'application/json', 'X-API-Key': config.api_key},
|
||||
follow_redirects=False,
|
||||
)
|
||||
if type(getattr(response, 'status_code', None)) is not int or response.status_code != 200:
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
payload = response.json()
|
||||
except Exception: # noqa: BLE001 - never expose a Docker/API credential or response.
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
if not isinstance(payload, list):
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
|
||||
npm = []
|
||||
frontdoor = []
|
||||
for raw in payload:
|
||||
if not isinstance(raw, dict):
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
names = raw.get('Names')
|
||||
if not isinstance(names, list) or any(not isinstance(name, str) for name in names):
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
name_matches = any(name.lstrip('/') == 'NginxProxyManager' for name in names)
|
||||
image = raw.get('Image')
|
||||
if not isinstance(image, str) or not image:
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
image_repository = image.rsplit('@', 1)[0]
|
||||
last_component = image_repository.rsplit('/', 1)[-1]
|
||||
if ':' in last_component:
|
||||
image_repository = image_repository.rsplit(':', 1)[0]
|
||||
image_repository = image_repository.lower()
|
||||
if name_matches:
|
||||
if image_repository != 'jlesage/nginx-proxy-manager':
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
npm.append(raw)
|
||||
labels = raw.get('Labels')
|
||||
if (
|
||||
isinstance(labels, dict)
|
||||
and labels.get('com.docker.compose.project') == 'itworx-forge'
|
||||
and labels.get('com.docker.compose.service') == 'itworx-forge'
|
||||
):
|
||||
frontdoor.append(raw)
|
||||
if len(npm) != 1 or len(frontdoor) != 1:
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
npm_id = npm[0].get('Id')
|
||||
frontdoor_id = frontdoor[0].get('Id')
|
||||
if (
|
||||
not isinstance(npm_id, str)
|
||||
or not re.fullmatch(r'[0-9a-f]{64}', npm_id)
|
||||
or not isinstance(frontdoor_id, str)
|
||||
or not re.fullmatch(r'[0-9a-f]{64}', frontdoor_id)
|
||||
or npm_id == frontdoor_id
|
||||
):
|
||||
return {
|
||||
'status': 'unknown',
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': None,
|
||||
'frontdoorAttached': None,
|
||||
}
|
||||
|
||||
def _attached(raw: dict[str, Any]) -> tuple[str, str] | bool | None:
|
||||
if raw.get('State') != 'running':
|
||||
return False
|
||||
network_settings = raw.get('NetworkSettings')
|
||||
networks = network_settings.get('Networks') if isinstance(network_settings, dict) else None
|
||||
if not isinstance(networks, dict):
|
||||
return None
|
||||
if 'itworx-mcp-hub-npm' not in networks:
|
||||
return False
|
||||
network = networks.get('itworx-mcp-hub-npm')
|
||||
if not isinstance(network, dict):
|
||||
return None
|
||||
network_id = network.get('NetworkID')
|
||||
endpoint_id = network.get('EndpointID')
|
||||
if (
|
||||
not isinstance(network_id, str)
|
||||
or re.fullmatch(r'[0-9a-f]{64}', network_id) is None
|
||||
or not isinstance(endpoint_id, str)
|
||||
or re.fullmatch(r'[0-9a-f]{64}', endpoint_id) is None
|
||||
):
|
||||
return None
|
||||
return network_id, endpoint_id
|
||||
|
||||
npm_membership = _attached(npm[0])
|
||||
frontdoor_membership = _attached(frontdoor[0])
|
||||
if npm_membership is None or frontdoor_membership is None:
|
||||
status = 'unknown'
|
||||
elif npm_membership is False or frontdoor_membership is False:
|
||||
status = 'not_ready'
|
||||
elif npm_membership[0] != frontdoor_membership[0]:
|
||||
status = 'unknown'
|
||||
else:
|
||||
status = 'ready'
|
||||
npm_attached = None if npm_membership is None else npm_membership is not False
|
||||
frontdoor_attached = None if frontdoor_membership is None else frontdoor_membership is not False
|
||||
return {
|
||||
'status': status,
|
||||
'network': 'itworx-mcp-hub-npm',
|
||||
'npmAttached': npm_attached,
|
||||
'frontdoorAttached': frontdoor_attached,
|
||||
}
|
||||
|
||||
def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None:
|
||||
candidates = [
|
||||
container
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ class Settings:
|
||||
"NPM_URL": _env_value("NPM_URL", None),
|
||||
"NPM_IDENTITY": _env_value("NPM_IDENTITY", None),
|
||||
"NPM_SECRET": _env_value("NPM_SECRET", None),
|
||||
"NPM_VERIFY_TLS": _env_bool("NPM_VERIFY_TLS", self.verify_tls),
|
||||
"NPM_VERIFY_TLS": _env_bool("NPM_VERIFY_TLS", True),
|
||||
"NPM_TIMEOUT_SECONDS": _env_value("NPM_TIMEOUT_SECONDS", "10"),
|
||||
"NPM_LETSENCRYPT_EMAIL": _env_value("NPM_LETSENCRYPT_EMAIL", None),
|
||||
"AUTHENTIK_URL": _env_value("AUTHENTIK_URL", None),
|
||||
|
||||
@@ -950,4 +950,5 @@ def _seed_policies() -> list[Policy]:
|
||||
Policy(id="policy-authentik-unknown-mutation", action="authentik.unknown_mutation", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Unknown Authentik mutations fail closed."),
|
||||
Policy(id="policy-forge-oidc-provision", action="forge.oidc.provision", target="itworx-forge", voice="blocked", manual="allowed", risk="critical", reason="The fixed Forge OIDC client may be provisioned only by an authenticated manual operator route; voice and generic Authentik mutations remain blocked."),
|
||||
Policy(id="policy-forge-secret-prepare", action="forge.secrets.prepare", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Preparing the fixed Forge bootstrap secret bundle is default-denied and requires an explicit exact-target manual operator policy; voice remains blocked."),
|
||||
Policy(id="policy-forge-route-stage", action="forge.route.stage", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Staging the fixed disabled Forge proxy host is default-denied; voice remains blocked."),
|
||||
]
|
||||
+117
-1
@@ -46,8 +46,8 @@ from .services.policy_engine import evaluate_policy
|
||||
from .services.privatemuse_artifact_installer import ArtifactInstallError
|
||||
from .services.privatemuse_artifact_installer import install as install_artifact_file
|
||||
from .services.privatemuse_artifact_installer import preflight as artifact_preflight
|
||||
from .services.runtime_service import runtime_info
|
||||
from .services.runner_statistics import RunnerStatisticsService
|
||||
from .services.runtime_service import runtime_info
|
||||
|
||||
_SESSION_RUNNING = False
|
||||
_SESSION_VERIFIED_AT: str | None = None
|
||||
@@ -623,6 +623,122 @@ def appops_self_upgrade_status(operation_id: str) -> dict[str, Any]:
|
||||
return _json(DeploymentBrokerClient().appops_self_upgrade_status(operation_id))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_auto_apply.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.hub_auto_apply.preflight")
|
||||
def appops_hub_auto_apply_preflight(repository: str) -> dict[str, Any]:
|
||||
"""Read the fixed registered Hub policy fingerprint without secret values."""
|
||||
return _json(DeploymentBrokerClient().hub_auto_apply({"repository": repository}, preflight=True))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_auto_apply.set", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("appops.hub_auto_apply.set")
|
||||
def appops_hub_auto_apply_set(repository: str, held: bool, expected_registry_sha256: str,
|
||||
reason: str, request_id: str) -> dict[str, Any]:
|
||||
"""CAS-hold the Hub full-stack auto-rollout while retaining every validation gate."""
|
||||
return _json(DeploymentBrokerClient().hub_auto_apply({"repository": repository, "held": held,
|
||||
"expectedRegistrySha256": expected_registry_sha256, "reason": reason, "requestId": request_id}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_component.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.hub_component.preflight")
|
||||
def appops_hub_component_preflight(repository: str, service: str, revision: str,
|
||||
expected_baseline: str, reason: str, request_id: str) -> dict[str, Any]:
|
||||
"""Prepare one immutable protected-main connector promotion and fingerprint its rollback baseline."""
|
||||
return _json(DeploymentBrokerClient().hub_component_operation("preflight", {
|
||||
"repository": repository, "service": service, "revision": revision,
|
||||
"expectedBaseline": expected_baseline, "reason": reason, "requestId": request_id,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_component.baseline", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.hub_component.baseline")
|
||||
def appops_hub_component_baseline(repository: str, service: str) -> dict[str, Any]:
|
||||
"""Read the closed connector baseline and unrelated container identity fingerprint."""
|
||||
return _json(DeploymentBrokerClient().hub_component_operation("baseline", {
|
||||
"repository": repository, "service": service,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_component.apply", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("appops.hub_component.apply")
|
||||
def appops_hub_component_apply(repository: str, service: str, revision: str,
|
||||
expected_baseline: str, reason: str, request_id: str,
|
||||
plan_id: str, plan_digest: str) -> dict[str, Any]:
|
||||
"""Queue the exact preflighted closed connector target; caller-selected images and paths are forbidden."""
|
||||
return _json(DeploymentBrokerClient().hub_component_operation("apply", {
|
||||
"repository": repository, "service": service, "revision": revision,
|
||||
"expectedBaseline": expected_baseline, "reason": reason, "requestId": request_id,
|
||||
"planId": plan_id, "planDigest": plan_digest,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_component.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.hub_component.status")
|
||||
def appops_hub_component_status(plan_id: str) -> dict[str, Any]:
|
||||
"""Read one durable component receipt without advancing the full Hub revision."""
|
||||
return _json(DeploymentBrokerClient().hub_component_operation("status", {"planId": plan_id}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.registered_legacy.baseline", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.registered_legacy.baseline")
|
||||
def appops_registered_legacy_baseline(repository: str, request_id: str) -> dict[str, Any]:
|
||||
"""Observe an existing registered runtime whose previous source revision is unknown."""
|
||||
return _json(DeploymentBrokerClient().registered_legacy_operation("baseline", {
|
||||
"repository": repository, "requestId": request_id,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.registered_legacy.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.registered_legacy.preflight")
|
||||
def appops_registered_legacy_preflight(repository: str, revision: str, request_id: str,
|
||||
expected_state_sha256: str) -> dict[str, Any]:
|
||||
"""Validate exact protected-main push evidence and the actual rollback baseline."""
|
||||
return _json(DeploymentBrokerClient().registered_legacy_operation("preflight", {
|
||||
"repository": repository, "revision": revision, "requestId": request_id,
|
||||
"expectedStateSha256": expected_state_sha256,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.registered_legacy.adopt", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("appops.registered_legacy.adopt")
|
||||
def appops_registered_legacy_adopt(repository: str, revision: str, request_id: str,
|
||||
expected_state_sha256: str, preflight_sha256: str) -> dict[str, Any]:
|
||||
"""Authorize one CAS-bound unknown-image rollback snapshot without changing the runtime."""
|
||||
return _json(DeploymentBrokerClient().registered_legacy_operation("adopt", {
|
||||
"repository": repository, "revision": revision, "requestId": request_id,
|
||||
"expectedStateSha256": expected_state_sha256, "preflightSha256": preflight_sha256,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.registered_legacy.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.registered_legacy.status")
|
||||
def appops_registered_legacy_status(repository: str, request_id: str) -> dict[str, Any]:
|
||||
"""Read safe signed adoption metadata; credentials and runtime configuration stay private."""
|
||||
return _json(DeploymentBrokerClient().registered_legacy_operation("status", {
|
||||
"repository": repository, "requestId": request_id,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.registered_legacy.rollback", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("appops.registered_legacy.rollback")
|
||||
def appops_registered_legacy_rollback(repository: str, revision: str, request_id: str,
|
||||
expected_state_sha256: str) -> dict[str, Any]:
|
||||
"""Restore the signed previous image/configuration only if the verified current target matches."""
|
||||
return _json(DeploymentBrokerClient().registered_legacy_operation("rollback", {
|
||||
"repository": repository, "revision": revision, "requestId": request_id,
|
||||
"expectedStateSha256": expected_state_sha256,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.registered_legacy.delivery", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("appops.registered_legacy.delivery")
|
||||
def appops_registered_legacy_delivery(repository: str, revision: str, request_id: str) -> dict[str, Any]:
|
||||
"""Revalidate one original signed delivery after its registered baseline has been adopted."""
|
||||
return _json(DeploymentBrokerClient().registered_legacy_operation("delivery", {
|
||||
"repository": repository, "revision": revision, "requestId": request_id,
|
||||
}))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="server.operation.execute", annotations=_LEGACY_APPROVAL_EXECUTION_TOOL)
|
||||
@_tracked("server.operation.execute")
|
||||
def execute_operation(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, model_validator
|
||||
|
||||
_JOB = re.compile(r"^forge-install-[0-9a-f]{64}$")
|
||||
_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$")
|
||||
|
||||
|
||||
class ForgeRouteStageResultV1(BaseModel):
|
||||
"""Safe public projection for the disabled-only Forge route staging operation."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
frozen=True,
|
||||
populate_by_name=True,
|
||||
strict=True,
|
||||
)
|
||||
|
||||
success: StrictBool
|
||||
status: Literal[
|
||||
"host_staged_disabled",
|
||||
"blocked",
|
||||
"conflict",
|
||||
"failed",
|
||||
"outcome_unknown",
|
||||
"not_found",
|
||||
]
|
||||
message: StrictStr = Field(pattern=_SAFE_CODE.pattern)
|
||||
target: Literal["itworx-forge"]
|
||||
job_id: StrictStr = Field(alias="jobId", pattern=_JOB.pattern)
|
||||
hostname: Literal["forge.example.com"]
|
||||
upstream: Literal["http://itworx-forge:8080"]
|
||||
route_state: Literal["host_staged_disabled", "not_routed"] = Field(alias="routeState")
|
||||
routed: StrictBool
|
||||
|
||||
@model_validator(mode="after")
|
||||
def state_is_disabled_only(self) -> ForgeRouteStageResultV1:
|
||||
staged = self.status == "host_staged_disabled"
|
||||
if (
|
||||
self.success is not staged
|
||||
or self.route_state != ("host_staged_disabled" if staged else "not_routed")
|
||||
or self.routed is not False
|
||||
):
|
||||
raise ValueError("Forge route stage results never activate a route")
|
||||
return self
|
||||
@@ -1555,7 +1555,11 @@ def _execute_controller_artifact_promote(
|
||||
reason: str,
|
||||
original_policy_decision: str,
|
||||
) -> dict[str, Any]:
|
||||
from .controller_artifact_promoter import ControllerArtifactPromoter
|
||||
from .controller_artifact_promoter import (
|
||||
ControllerArtifactPromoter,
|
||||
helper_failure_diagnostics,
|
||||
safe_helper_diagnostics,
|
||||
)
|
||||
|
||||
action = "controller.artifact.promote"
|
||||
target = "tower/deployment-controller"
|
||||
@@ -1595,12 +1599,43 @@ def _execute_controller_artifact_promote(
|
||||
success = False
|
||||
message = f"{type(exc).__name__}: controller artifact promotion failed"
|
||||
|
||||
diagnostics = None
|
||||
if status == "failed" and result.get("reason") == "controller_promoter_helper_failed":
|
||||
diagnostics = safe_helper_diagnostics(result.get("diagnostics"))
|
||||
if diagnostics is None:
|
||||
diagnostics = helper_failure_diagnostics(result.get("exitCode"))
|
||||
exit_code = diagnostics["exitCode"]
|
||||
exit_code_summary = str(exit_code) if exit_code is not None else "unknown"
|
||||
message = (
|
||||
f"Controller promotion helper failed: {diagnostics['code']}; "
|
||||
f"phase={diagnostics['phase']}; exitCode={exit_code_summary}."
|
||||
)
|
||||
|
||||
completed = complete_job(
|
||||
session,
|
||||
job,
|
||||
status="succeeded" if success else "failed",
|
||||
result_summary=message,
|
||||
)
|
||||
after_state = {
|
||||
"status": status,
|
||||
"revision": result.get("revision"),
|
||||
"controllerVersion": result.get("controllerVersion"),
|
||||
"artifactSha256": result.get("artifactSha256"),
|
||||
}
|
||||
redacted_metadata = {
|
||||
"executor": action,
|
||||
"integration": "mcp" if source == "chatgpt-mcp" else "operator",
|
||||
"originalPolicyDecision": original_policy_decision,
|
||||
"callerRevisionSelectable": False,
|
||||
"callerCommandSelectable": False,
|
||||
"callerMountSelectable": False,
|
||||
"helperTargetSelectable": False,
|
||||
}
|
||||
if diagnostics is not None:
|
||||
after_state["diagnostics"] = diagnostics
|
||||
redacted_metadata["diagnostics"] = diagnostics
|
||||
|
||||
audit = log_event(
|
||||
session,
|
||||
source="action",
|
||||
@@ -1613,23 +1648,10 @@ def _execute_controller_artifact_promote(
|
||||
reason=reason,
|
||||
result="success" if success else "failed",
|
||||
execution_plan={"steps": steps, "jobId": completed.id},
|
||||
after_state={
|
||||
"status": status,
|
||||
"revision": result.get("revision"),
|
||||
"controllerVersion": result.get("controllerVersion"),
|
||||
"artifactSha256": result.get("artifactSha256"),
|
||||
},
|
||||
redacted_metadata={
|
||||
"executor": action,
|
||||
"integration": "mcp" if source == "chatgpt-mcp" else "operator",
|
||||
"originalPolicyDecision": original_policy_decision,
|
||||
"callerRevisionSelectable": False,
|
||||
"callerCommandSelectable": False,
|
||||
"callerMountSelectable": False,
|
||||
"helperTargetSelectable": False,
|
||||
},
|
||||
after_state=after_state,
|
||||
redacted_metadata=redacted_metadata,
|
||||
)
|
||||
return {
|
||||
operation_result = {
|
||||
"success": success,
|
||||
"status": "success" if success else status,
|
||||
"result": "success" if success else "failed",
|
||||
@@ -1642,6 +1664,9 @@ def _execute_controller_artifact_promote(
|
||||
"controllerVersion": result.get("controllerVersion"),
|
||||
"artifactSha256": result.get("artifactSha256"),
|
||||
}
|
||||
if diagnostics is not None:
|
||||
operation_result["diagnostics"] = diagnostics
|
||||
return operation_result
|
||||
|
||||
|
||||
def _execute_runner_fleet_upgrade_v2(
|
||||
|
||||
@@ -178,6 +178,7 @@ ACTIONS = [
|
||||
'authentik.unknown_mutation',
|
||||
'forge.oidc.provision',
|
||||
'forge.secrets.prepare',
|
||||
'forge.route.stage',
|
||||
'tautulli.terminate_session',
|
||||
'tautulli.delete_newsletter',
|
||||
'tautulli.delete_notification_log',
|
||||
@@ -390,6 +391,7 @@ ACTION_RISK = {
|
||||
'authentik.unknown_mutation': 'critical',
|
||||
'forge.oidc.provision': 'critical',
|
||||
'forge.secrets.prepare': 'critical',
|
||||
'forge.route.stage': 'critical',
|
||||
'tautulli.terminate_session': 'high',
|
||||
'tautulli.delete_newsletter': 'critical',
|
||||
'tautulli.delete_notification_log': 'critical',
|
||||
@@ -539,6 +541,8 @@ def action_default_target(action: str) -> str:
|
||||
return 'itworx-forge'
|
||||
if action == 'forge.secrets.prepare':
|
||||
return 'itworx-forge'
|
||||
if action == 'forge.route.stage':
|
||||
return 'itworx-forge'
|
||||
if action == 'controller.artifact.promote':
|
||||
return 'tower/deployment-controller'
|
||||
if action == 'controller.managed-storage.initialize':
|
||||
|
||||
@@ -43,24 +43,68 @@ def helper_container_spec(
|
||||
raise ValueError("controller_promoter_bootstrap_binding_invalid")
|
||||
|
||||
command = f"""set -Eeuo pipefail
|
||||
phase=shell_setup
|
||||
work=''
|
||||
diagnostic_emitted=0
|
||||
emit_failure() {{
|
||||
local exit_code="$1"
|
||||
if (( diagnostic_emitted == 0 )); then
|
||||
printf 'APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=%s exitCode=%s\\n' "$phase" "$exit_code" >&2
|
||||
diagnostic_emitted=1
|
||||
fi
|
||||
}}
|
||||
on_error() {{
|
||||
local exit_code=$?
|
||||
trap - ERR
|
||||
emit_failure "$exit_code"
|
||||
exit "$exit_code"
|
||||
}}
|
||||
cleanup() {{
|
||||
local exit_code=$?
|
||||
local cleanup_exit_code=0
|
||||
trap - EXIT
|
||||
phase=cleanup
|
||||
if [[ -n "$work" ]]; then
|
||||
if rm -rf -- "$work"; then
|
||||
:
|
||||
else
|
||||
cleanup_exit_code=$?
|
||||
emit_failure "$cleanup_exit_code"
|
||||
if (( exit_code == 0 )); then exit_code=$cleanup_exit_code; fi
|
||||
fi
|
||||
fi
|
||||
exit "$exit_code"
|
||||
}}
|
||||
trap on_error ERR
|
||||
umask 077
|
||||
revision='{revision}'
|
||||
version='{CONTROLLER_VERSION}'
|
||||
image_id='{image_id}'
|
||||
repo='/gitea-repositories/jens/unraid-appops-gateway.git'
|
||||
[[ \"$(stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]]
|
||||
work=\"$(mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\"
|
||||
phase=tmp_root
|
||||
[[ \"$(trap - ERR; stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]]
|
||||
phase=workspace_create
|
||||
work=\"$(trap - ERR; mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\"
|
||||
phase=workspace_validate
|
||||
[[ -d \"$work\" && ! -L \"$work\" && \"$(stat -c '%u:%a' \"$work\")\" == '0:700' ]]
|
||||
cleanup() {{ rm -rf -- \"$work\"; }}
|
||||
trap cleanup EXIT
|
||||
phase=tool_git
|
||||
command -v git >/dev/null
|
||||
phase=tool_docker
|
||||
command -v docker >/dev/null
|
||||
phase=tool_compose
|
||||
docker compose version >/dev/null
|
||||
phase=tool_python
|
||||
command -v python3 >/dev/null
|
||||
phase=tool_tar
|
||||
command -v tar >/dev/null
|
||||
phase=tool_curl
|
||||
command -v curl >/dev/null
|
||||
phase=source_commit
|
||||
git --git-dir=\"$repo\" cat-file -e \"$revision^{{commit}}\"
|
||||
tree=\"$(git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\"
|
||||
phase=source_tree
|
||||
tree=\"$(trap - ERR; git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\"
|
||||
phase=source_archive
|
||||
git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\
|
||||
ops/gitea-deploy-broker \\
|
||||
scripts/prepare_controller_artifact.py \\
|
||||
@@ -69,10 +113,15 @@ git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\
|
||||
scripts/vendor \\
|
||||
scripts/run_repopulse_adoption.py \
|
||||
scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"
|
||||
phase=archive_permissions
|
||||
chmod 0600 \"$work/controller.tar\"
|
||||
archive_sha=\"$(sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\"
|
||||
phase=archive_digest
|
||||
archive_sha=\"$(trap - ERR; sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\"
|
||||
phase=upgrade_script
|
||||
git --git-dir=\"$repo\" show \"$revision:scripts/upgrade_deployment_controller.sh\" > \"$work/upgrade.sh\"
|
||||
phase=upgrade_permissions
|
||||
chmod 0700 \"$work/upgrade.sh\"
|
||||
phase=upgrade
|
||||
bash \"$work/upgrade.sh\" \\
|
||||
--archive \"$work/controller.tar\" \\
|
||||
--archive-sha256 \"$archive_sha\" \\
|
||||
|
||||
@@ -10,16 +10,17 @@ import httpx
|
||||
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from .controller_artifact_helper_spec import (
|
||||
_IMAGE_ID,
|
||||
_REVISION,
|
||||
CONTROLLER_VERSION,
|
||||
HELPER_LABEL,
|
||||
HELPER_NAME,
|
||||
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
|
||||
_IMAGE_ID,
|
||||
_REVISION,
|
||||
helper_container_spec,
|
||||
)
|
||||
from .controller_artifact_helper_spec import (
|
||||
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
|
||||
)
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
|
||||
GITEA_OWNER = "Jens"
|
||||
@@ -28,6 +29,132 @@ REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)"
|
||||
CONTROLLER_CONTAINER = "gitea-deploy-control"
|
||||
_HELPER_TIMEOUT_SECONDS = 240
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$")
|
||||
_MAX_HELPER_LOG_CHARS = 4000
|
||||
_MAX_HELPER_LOG_LINES = 200
|
||||
_MAX_HELPER_LOG_WIRE_BYTES = _MAX_HELPER_LOG_CHARS + (8 * _MAX_HELPER_LOG_LINES)
|
||||
_HELPER_DIAGNOSTIC_PREFIX = "APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC"
|
||||
_HELPER_DIAGNOSTIC_CODES = frozenset({"helper_failed"})
|
||||
_HELPER_DIAGNOSTIC_PHASES = frozenset(
|
||||
{
|
||||
"shell_setup",
|
||||
"tmp_root",
|
||||
"workspace_create",
|
||||
"workspace_validate",
|
||||
"tool_git",
|
||||
"tool_docker",
|
||||
"tool_compose",
|
||||
"tool_python",
|
||||
"tool_tar",
|
||||
"tool_curl",
|
||||
"source_commit",
|
||||
"source_tree",
|
||||
"source_archive",
|
||||
"archive_permissions",
|
||||
"archive_digest",
|
||||
"upgrade_script",
|
||||
"upgrade_permissions",
|
||||
"upgrade",
|
||||
"cleanup",
|
||||
}
|
||||
)
|
||||
_HELPER_DIAGNOSTIC_LINE = re.compile(
|
||||
rf"^{_HELPER_DIAGNOSTIC_PREFIX} code=([a-z_]+) phase=([a-z_]+) "
|
||||
r"exitCode=(0|[1-9][0-9]{0,2})$"
|
||||
)
|
||||
|
||||
|
||||
def helper_failure_diagnostics(exit_code: Any) -> dict[str, Any]:
|
||||
safe_exit_code = exit_code if type(exit_code) is int and 0 <= exit_code <= 255 else None
|
||||
return {"code": "helper_failed", "phase": "unknown", "exitCode": safe_exit_code}
|
||||
|
||||
|
||||
def safe_helper_diagnostics(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict) or set(value) != {"code", "phase", "exitCode"}:
|
||||
return None
|
||||
code = value.get("code")
|
||||
phase = value.get("phase")
|
||||
exit_code = value.get("exitCode")
|
||||
if not isinstance(code, str) or code not in _HELPER_DIAGNOSTIC_CODES:
|
||||
return None
|
||||
if not isinstance(phase, str) or (
|
||||
phase != "unknown" and phase not in _HELPER_DIAGNOSTIC_PHASES
|
||||
):
|
||||
return None
|
||||
if exit_code is not None and (
|
||||
type(exit_code) is not int or not 0 <= exit_code <= 255
|
||||
):
|
||||
return None
|
||||
return {"code": code, "phase": phase, "exitCode": exit_code}
|
||||
|
||||
|
||||
def parse_helper_diagnostics(log_text: Any, exit_code: Any) -> dict[str, Any]:
|
||||
fallback = helper_failure_diagnostics(exit_code)
|
||||
if (
|
||||
fallback["exitCode"] is None
|
||||
or not isinstance(log_text, str)
|
||||
or len(log_text) > _MAX_HELPER_LOG_CHARS
|
||||
):
|
||||
return fallback
|
||||
|
||||
candidates = [
|
||||
line
|
||||
for line in log_text.splitlines()
|
||||
if line.startswith(_HELPER_DIAGNOSTIC_PREFIX)
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
return fallback
|
||||
match = _HELPER_DIAGNOSTIC_LINE.fullmatch(candidates[0])
|
||||
if match is None:
|
||||
return fallback
|
||||
code, phase, marker_exit_code = match.groups()
|
||||
parsed_exit_code = int(marker_exit_code)
|
||||
if (
|
||||
code not in _HELPER_DIAGNOSTIC_CODES
|
||||
or phase not in _HELPER_DIAGNOSTIC_PHASES
|
||||
or parsed_exit_code != fallback["exitCode"]
|
||||
):
|
||||
return fallback
|
||||
return {"code": code, "phase": phase, "exitCode": parsed_exit_code}
|
||||
|
||||
|
||||
def _decode_docker_log_stream(content: Any) -> str | None:
|
||||
if (
|
||||
not isinstance(content, bytes)
|
||||
or not content
|
||||
or len(content) > _MAX_HELPER_LOG_WIRE_BYTES
|
||||
):
|
||||
return None
|
||||
|
||||
offset = 0
|
||||
frame_count = 0
|
||||
payload_size = 0
|
||||
payloads: list[bytes] = []
|
||||
while offset < len(content):
|
||||
if frame_count >= _MAX_HELPER_LOG_LINES or len(content) - offset < 8:
|
||||
return None
|
||||
|
||||
header = content[offset : offset + 8]
|
||||
stream_type = header[0]
|
||||
if stream_type not in {1, 2} or header[1:4] != b"\x00\x00\x00":
|
||||
return None
|
||||
frame_size = int.from_bytes(header[4:8], "big")
|
||||
if frame_size == 0 or frame_size > _MAX_HELPER_LOG_CHARS - payload_size:
|
||||
return None
|
||||
|
||||
payload_start = offset + 8
|
||||
payload_end = payload_start + frame_size
|
||||
if payload_end > len(content):
|
||||
return None
|
||||
|
||||
payloads.append(content[payload_start:payload_end])
|
||||
payload_size += frame_size
|
||||
frame_count += 1
|
||||
offset = payload_end
|
||||
|
||||
try:
|
||||
return b"".join(payloads).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
class ControllerArtifactPromotionError(RuntimeError):
|
||||
@@ -257,10 +384,29 @@ class ControllerArtifactPromoter:
|
||||
}
|
||||
|
||||
exit_code = state.get("ExitCode")
|
||||
logs = self.portainer.get_container_logs(helper_id, tail=200)
|
||||
log_lines = logs.get("logs") if isinstance(logs, dict) and isinstance(logs.get("logs"), list) else []
|
||||
tail = "\n".join(str(line) for line in log_lines[-40:])[-4000:]
|
||||
if exit_code != 0:
|
||||
if type(exit_code) is not int or exit_code != 0:
|
||||
try:
|
||||
logs_response = self.portainer._request(
|
||||
"GET",
|
||||
f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/logs",
|
||||
params={
|
||||
"stdout": 1,
|
||||
"stderr": 1,
|
||||
"tail": _MAX_HELPER_LOG_LINES,
|
||||
},
|
||||
)
|
||||
log_text = (
|
||||
_decode_docker_log_stream(logs_response.content)
|
||||
if isinstance(logs_response, httpx.Response)
|
||||
and logs_response.status_code == 200
|
||||
else None
|
||||
)
|
||||
except (httpx.HTTPError, RuntimeError, ValueError):
|
||||
log_text = None
|
||||
diagnostics = parse_helper_diagnostics(
|
||||
log_text,
|
||||
exit_code,
|
||||
)
|
||||
try:
|
||||
self._remove_helper(helper_id)
|
||||
except RuntimeError:
|
||||
@@ -269,15 +415,14 @@ class ControllerArtifactPromoter:
|
||||
"status": "failed",
|
||||
"revision": revision,
|
||||
"reason": "controller_promoter_helper_failed",
|
||||
"exitCode": exit_code,
|
||||
"logTail": redact_message(tail),
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
try:
|
||||
self._remove_helper(helper_id)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return {"status": "promoted", "revision": revision, "logTail": redact_message(tail)}
|
||||
return {"status": "promoted", "revision": revision}
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
revision = self._validated_main_revision()
|
||||
|
||||
@@ -214,9 +214,7 @@ class DeploymentBrokerClient:
|
||||
}
|
||||
for field in error_response_fields:
|
||||
value = decoded.get(field)
|
||||
if isinstance(value, str) and len(value) <= 256:
|
||||
rejected[field] = value
|
||||
elif field == "noMutation" and isinstance(value, bool):
|
||||
if isinstance(value, str) and len(value) <= 256 or field == "noMutation" and isinstance(value, bool):
|
||||
rejected[field] = value
|
||||
return rejected
|
||||
return {"status": str(decoded.get("status") or "ok"), **decoded}
|
||||
@@ -279,6 +277,33 @@ class DeploymentBrokerClient:
|
||||
"reason": reason, "expectedCurrentRevision": expected_current_revision},
|
||||
)
|
||||
|
||||
def hub_component_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Only closed, signed component-promotion endpoints; never caller commands."""
|
||||
if stage not in {"baseline", "preflight", "apply", "status"}:
|
||||
raise ValueError("component promotion stage is invalid")
|
||||
return self._request(
|
||||
"POST", f"/v1/appops/hub-components/{stage}",
|
||||
secret=settings.mcp_token or "", payload=payload,
|
||||
error_response_fields=frozenset({"planId", "planDigest", "noMutation"}),
|
||||
)
|
||||
|
||||
def hub_auto_apply(self, payload: dict[str, Any], *, preflight: bool = False) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST", "/v1/appops/registered-targets/auto-apply" + ("/preflight" if preflight else ""),
|
||||
secret=settings.mcp_token or "", payload=payload,
|
||||
error_response_fields=frozenset({"noMutation"}),
|
||||
)
|
||||
|
||||
def registered_legacy_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Signed fixed-stage metadata adoption/recovery for existing registered targets."""
|
||||
if stage not in {"baseline", "preflight", "adopt", "status", "rollback", "delivery"}:
|
||||
raise ValueError("registered legacy stage is invalid")
|
||||
return self._request(
|
||||
"POST", f"/v1/appops/registered-legacy/{stage}",
|
||||
secret=settings.mcp_token or "", payload=payload,
|
||||
error_response_fields=frozenset({"noMutation"}),
|
||||
)
|
||||
|
||||
def appops_self_upgrade_apply(
|
||||
self, revision: str, reason: str, expected_current_revision: str | None,
|
||||
preflight_sha256: str, idempotency_key: str,
|
||||
|
||||
@@ -26,6 +26,7 @@ import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -36,6 +37,8 @@ host_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
|
||||
repository, revision, branch, raw_project = sys.argv[1:5]
|
||||
project = json.loads(raw_project)
|
||||
requested_project = project
|
||||
strengthening_deadline = time.monotonic() + 20
|
||||
|
||||
def _validation_compatible(repository, validation):
|
||||
expected_validation = {
|
||||
@@ -58,6 +61,52 @@ def _validation_compatible(repository, validation):
|
||||
"contexts": ["CI / gates (push)", "Managed validation / full (push)"],
|
||||
}
|
||||
|
||||
def _source_verified_strengthening():
|
||||
# Only an exact protected-main descriptor can strengthen an existing target.
|
||||
# The controller reader uses fixed credential files and private stdin.
|
||||
if branch != "main" or time.monotonic() >= strengthening_deadline:
|
||||
return False
|
||||
try:
|
||||
fresh = git_run("rev-parse", "refs/heads/main")
|
||||
if fresh.returncode or fresh.stdout.strip() != revision:
|
||||
return False
|
||||
result = git_run("show", f"{revision}:.itworx/deployment-target.json")
|
||||
if result.returncode or len(result.stdout.encode("utf-8")) > 16384:
|
||||
return False
|
||||
def unique(pairs):
|
||||
value = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate descriptor field")
|
||||
value[key] = item
|
||||
return value
|
||||
descriptor = json.loads(result.stdout, object_pairs_hook=unique)
|
||||
allowed = {"apiVersion", "applicationId", "composeFile", "composeProject",
|
||||
"healthTimeout", "primaryService", "verifyOciRevision"}
|
||||
if (not isinstance(descriptor, dict) or set(descriptor) - allowed
|
||||
or descriptor.get("apiVersion") != "itworx.deployment-target/v1"
|
||||
or descriptor.get("verifyOciRevision") is not True
|
||||
or descriptor.get("composeFile") != compose_file
|
||||
or descriptor.get("composeProject", descriptor.get("applicationId")) != requested_project["compose_project"]
|
||||
or type(descriptor.get("healthTimeout", 180)) is not int
|
||||
or descriptor.get("healthTimeout", 180) != requested_project["health_timeout"]
|
||||
or not isinstance(descriptor.get("applicationId"), str)
|
||||
or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", descriptor["applicationId"])
|
||||
or not isinstance(descriptor.get("primaryService", descriptor["applicationId"]), str)
|
||||
or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", descriptor.get("primaryService", descriptor["applicationId"]))):
|
||||
return False
|
||||
sys.path.insert(0, "/opt/gitea-deploy")
|
||||
from status_server import _registered_legacy_api_reader
|
||||
protected = _registered_legacy_api_reader(repository)(
|
||||
f"/repos/{repository}/branches/main", query={},
|
||||
)
|
||||
return (time.monotonic() < strengthening_deadline
|
||||
and isinstance(protected, dict) and protected.get("protected") is True
|
||||
and isinstance(protected.get("commit"), dict)
|
||||
and protected["commit"].get("id") == revision)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
for directory in (path.parent, host_path.parent, host_path.parent.parent):
|
||||
info = directory.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
@@ -89,8 +138,16 @@ if not bare_repo.is_dir():
|
||||
raise SystemExit(24)
|
||||
|
||||
def git_run(*args):
|
||||
# Trust only this validated controller-owned mount, preserving Git's normal
|
||||
# checks elsewhere. Always read original objects rather than replacement refs.
|
||||
for directory in (bare_repo.parent.parent, bare_repo.parent, bare_repo):
|
||||
info = directory.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid not in {0, 1000}
|
||||
or info.st_mode & 0o022):
|
||||
raise ValueError("unsafe mounted repository")
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={bare_repo}", *args],
|
||||
["git", "--no-replace-objects", "-c", f"safe.directory={bare_repo}",
|
||||
f"--git-dir={bare_repo}", *args],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -168,12 +225,34 @@ with lock_handle as lock:
|
||||
"gitea_repository_id",
|
||||
"gitea_webhook_secret_file",
|
||||
"validation",
|
||||
"legacy_containers",
|
||||
}
|
||||
extra_keys = set(current) - set(project)
|
||||
if not extra_keys.issubset(allowed_extra_keys):
|
||||
raise SystemExit(23)
|
||||
canonical_current = {key: current.get(key) for key in project}
|
||||
if canonical_current != project:
|
||||
immutable_keys = set(project) - {"source_dir", "exact_source", "verify_oci_revision", "health_timeout"}
|
||||
if any(current.get(key) != project[key] for key in immutable_keys):
|
||||
raise SystemExit(23)
|
||||
# Preserve the registered source mode and fixed historical appdata path.
|
||||
# Registration must never silently migrate an existing runtime's source.
|
||||
source = current.get("source_dir")
|
||||
known_sources = {project["source_dir"], f"/srv/example/appdata/{project['slug']}/source"}
|
||||
exact_source = current.get("exact_source", False)
|
||||
verification = current.get("verify_oci_revision", False)
|
||||
health_timeout = current.get("health_timeout", 180)
|
||||
if (not isinstance(source, str) or source not in known_sources or type(exact_source) is not bool
|
||||
or type(verification) is not bool or type(project["verify_oci_revision"]) is not bool
|
||||
or type(health_timeout) is not int or health_timeout != project["health_timeout"]
|
||||
or verification and project["verify_oci_revision"] is not True):
|
||||
raise SystemExit(23)
|
||||
legacy_containers = current.get("legacy_containers")
|
||||
if legacy_containers is not None and (
|
||||
not isinstance(legacy_containers, list) or len(legacy_containers) > 32
|
||||
or any(not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name)
|
||||
for name in legacy_containers)
|
||||
or len(set(legacy_containers)) != len(legacy_containers)
|
||||
or set(legacy_containers).intersection(project["containers"])
|
||||
):
|
||||
raise SystemExit(23)
|
||||
env_source = current.get("env_source")
|
||||
if env_source is not None:
|
||||
@@ -222,6 +301,15 @@ with lock_handle as lock:
|
||||
if not _validation_compatible(repository, validation):
|
||||
raise SystemExit(23)
|
||||
|
||||
strengthened = not verification and project["verify_oci_revision"] is True
|
||||
if strengthened and not _source_verified_strengthening():
|
||||
raise SystemExit(39)
|
||||
reconciled = dict(current)
|
||||
if strengthened:
|
||||
reconciled["verify_oci_revision"] = True
|
||||
changed = reconciled != current
|
||||
project = reconciled
|
||||
|
||||
if changed:
|
||||
try:
|
||||
backup_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups")
|
||||
@@ -266,11 +354,17 @@ with lock_handle as lock:
|
||||
raise SystemExit(33)
|
||||
|
||||
try:
|
||||
if current is not None and strengthened and not _source_verified_strengthening():
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise SystemExit(39)
|
||||
latest = mutation_path.lstat()
|
||||
latest_identity = (latest.st_dev, latest.st_ino, latest.st_mode, latest.st_uid,
|
||||
latest.st_gid, latest.st_mtime_ns, latest.st_ctime_ns, latest.st_size)
|
||||
if latest_identity != registry_identity or mutation_path.read_bytes() != original or not os.path.samefile(path, mutation_path):
|
||||
raise SystemExit(34)
|
||||
if current is not None and strengthened and time.monotonic() >= strengthening_deadline:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise SystemExit(39)
|
||||
os.replace(temporary, mutation_path)
|
||||
except Exception:
|
||||
try:
|
||||
@@ -429,6 +523,8 @@ class DeploymentTargetRegistry:
|
||||
verify_oci_revision: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
if type(verify_oci_revision) is not bool:
|
||||
raise ValueError("OCI revision verification must be a boolean")
|
||||
(
|
||||
repository,
|
||||
revision,
|
||||
@@ -561,6 +657,7 @@ class DeploymentTargetRegistry:
|
||||
36: "deployment projects registry host alias is unavailable or does not match the controller registry",
|
||||
37: "controller project branch does not match the requested default branch",
|
||||
38: "controller project Compose contract is invalid",
|
||||
39: "existing target verification strengthening requires its exact protected-main source descriptor",
|
||||
}
|
||||
if exit_code in preflight_reasons:
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,7 @@ import secrets
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
@@ -46,6 +47,20 @@ _CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "ap
|
||||
_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"}
|
||||
_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
|
||||
_APPDATA_ROOT_STATUSES = {"ready", "needs_initialization", "unavailable"}
|
||||
_CAPABILITY_REASON = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
|
||||
_RUNTIME_SECURITY_FEATURES = ("exactTargetPolicy", "hmacV2", "replayProtection")
|
||||
|
||||
|
||||
def _safe_capability_reason(value: object) -> str:
|
||||
reason = str(value or "")
|
||||
return reason if _CAPABILITY_REASON.fullmatch(reason) else ""
|
||||
|
||||
|
||||
def _features_ready(features: object, names: tuple[str, ...]) -> bool:
|
||||
return bool(
|
||||
isinstance(features, dict)
|
||||
and all(features.get(name) is True for name in names)
|
||||
)
|
||||
|
||||
|
||||
def _trusted_appdata_roots_readiness(
|
||||
@@ -57,7 +72,7 @@ def _trusted_appdata_roots_readiness(
|
||||
if isinstance(reported, list):
|
||||
if len(reported) != len(roots):
|
||||
return [{"root": root, "status": unavailable[root]} for root in roots]
|
||||
statuses: dict[str, str] = {}
|
||||
statuses: dict[str, dict[str, str]] = {}
|
||||
for item in reported:
|
||||
if not isinstance(item, dict):
|
||||
return [{"root": root, "status": unavailable[root]} for root in roots]
|
||||
@@ -71,10 +86,14 @@ def _trusted_appdata_roots_readiness(
|
||||
or status not in _APPDATA_ROOT_STATUSES
|
||||
):
|
||||
return [{"root": expected, "status": unavailable[expected]} for expected in roots]
|
||||
statuses[root] = status
|
||||
value = {"root": root, "status": status}
|
||||
reason = _safe_capability_reason(item.get("reason"))
|
||||
if reason:
|
||||
value["reason"] = reason
|
||||
statuses[root] = value
|
||||
if set(statuses) != set(roots):
|
||||
return [{"root": root, "status": unavailable[root]} for root in roots]
|
||||
return [{"root": root, "status": statuses[root]} for root in roots]
|
||||
return [statuses[root] for root in roots]
|
||||
|
||||
# A legacy V1 controller reports only its configured root. It cannot attest
|
||||
# independent managed-volume readiness, so only its exact legacy root can
|
||||
@@ -224,11 +243,40 @@ class EnrollmentPlane:
|
||||
controller_root_matches = bool(selected_root and selected_root.get("status") == "ready")
|
||||
else:
|
||||
controller_root_matches = False
|
||||
broker_ready = bool(
|
||||
controller_core_ready = bool(
|
||||
bootstrap
|
||||
and self.broker.is_configured()
|
||||
and controller_capability.get("status") == "ready"
|
||||
)
|
||||
features = controller_capability.get("features")
|
||||
runtime_security_ready = _features_ready(features, _RUNTIME_SECURITY_FEATURES)
|
||||
apply_ready = bool(
|
||||
controller_core_ready
|
||||
and runtime_security_ready
|
||||
and isinstance(features, dict)
|
||||
and features.get("deploymentApply") is True
|
||||
)
|
||||
verify_ready = bool(
|
||||
controller_core_ready
|
||||
and runtime_security_ready
|
||||
and isinstance(features, dict)
|
||||
and features.get("deploymentVerify") is True
|
||||
)
|
||||
rollback_ready = bool(
|
||||
controller_core_ready
|
||||
and runtime_security_ready
|
||||
and isinstance(features, dict)
|
||||
and features.get("rollback") is True
|
||||
)
|
||||
runtime_deployment_ready = apply_ready and verify_ready
|
||||
enrollment_features_ready = _features_ready(
|
||||
features,
|
||||
("enrollment", "targetRegistration", "runtimeSecretSealing"),
|
||||
)
|
||||
target_enrollment_ready = bool(
|
||||
controller_core_ready
|
||||
and controller_root_matches
|
||||
and enrollment_features_ready
|
||||
)
|
||||
controller_managed_storage = controller_capability.get("managedStorage")
|
||||
if not isinstance(controller_managed_storage, dict):
|
||||
@@ -248,6 +296,41 @@ class EnrollmentPlane:
|
||||
else:
|
||||
managed_storage_status = "unavailable"
|
||||
managed_storage_blocker = "managed_appdata_volume_unavailable"
|
||||
controller_roots_by_path = {
|
||||
str(item.get("root") or ""): item
|
||||
for item in available_roots
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
selected_root_readiness = (
|
||||
controller_roots_by_path.get(trust.trusted_appdata_root)
|
||||
if trust is not None
|
||||
else None
|
||||
)
|
||||
controller_enrollment = controller_capability.get("enrollmentReadiness")
|
||||
if not isinstance(controller_enrollment, dict):
|
||||
controller_enrollment = {}
|
||||
root_reason = _safe_capability_reason(
|
||||
selected_root_readiness.get("reason")
|
||||
if isinstance(selected_root_readiness, dict)
|
||||
else None
|
||||
)
|
||||
if not root_reason and trust is not None and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT:
|
||||
root_reason = _safe_capability_reason(controller_managed_storage.get("reason"))
|
||||
enrollment_reason = (
|
||||
root_reason
|
||||
or _safe_capability_reason(controller_enrollment.get("reason"))
|
||||
or (
|
||||
"enrollment_trust_not_configured"
|
||||
if trust is None
|
||||
else "trusted_appdata_root_unavailable"
|
||||
if not controller_root_matches
|
||||
else "controller_enrollment_feature_unavailable"
|
||||
if not enrollment_features_ready
|
||||
else "deployment_executor_unavailable"
|
||||
)
|
||||
)
|
||||
if target_enrollment_ready:
|
||||
enrollment_reason = ""
|
||||
npm_ready = self.npm.is_configured()
|
||||
oidc_configured = bool(
|
||||
trust
|
||||
@@ -262,18 +345,44 @@ class EnrollmentPlane:
|
||||
def item(ready: bool, code: str) -> dict[str, str]:
|
||||
return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code}
|
||||
|
||||
if not controller_core_ready:
|
||||
enrollment_blocker = "deployment_executor_unavailable"
|
||||
elif not controller_root_matches:
|
||||
enrollment_blocker = "trusted_appdata_root_unavailable"
|
||||
else:
|
||||
enrollment_blocker = "controller_enrollment_feature_unavailable"
|
||||
registry_report = controller_capability.get("registry")
|
||||
registry_projection: dict[str, Any] = {"status": "unavailable"}
|
||||
if isinstance(registry_report, dict):
|
||||
registry_status = registry_report.get("status")
|
||||
if registry_status in {"ready", "unavailable"}:
|
||||
registry_projection["status"] = registry_status
|
||||
count = registry_report.get("registeredProjectCount")
|
||||
if (
|
||||
registry_status == "ready"
|
||||
and type(count) is int
|
||||
and 0 <= count <= 500
|
||||
):
|
||||
registry_projection["registeredProjectCount"] = count
|
||||
controller_reason = (
|
||||
""
|
||||
if controller_core_ready
|
||||
else _safe_capability_reason(controller_capability.get("reason"))
|
||||
or "deployment_executor_unavailable"
|
||||
)
|
||||
|
||||
return {
|
||||
"apiVersion": "itworx.enrollment-capabilities/v1",
|
||||
"trust": item(configured, "enrollment_trust_not_configured"),
|
||||
"targetEnrollment": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"targetEnrollment": item(target_enrollment_ready, enrollment_blocker),
|
||||
"secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"),
|
||||
"runtimeDeployment": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"runtimeDeployment": item(runtime_deployment_ready, "deployment_executor_unavailable"),
|
||||
"reverseProxyProvisioning": item(npm_ready, "npm_credentials_missing"),
|
||||
"tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"),
|
||||
"oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"),
|
||||
"policyProvisioning": item(configured, "policy_template_unavailable"),
|
||||
"rollback": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"verification": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"rollback": item(rollback_ready, "deployment_executor_unavailable"),
|
||||
"verification": item(verify_ready, "deployment_executor_unavailable"),
|
||||
"availableTrustedAppdataRoots": available_roots,
|
||||
"managedStorage": {
|
||||
"status": managed_storage_status,
|
||||
@@ -283,19 +392,23 @@ class EnrollmentPlane:
|
||||
"backupAcknowledged": bool(
|
||||
trust and trust.managed_appdata_backup_acknowledged
|
||||
),
|
||||
},
|
||||
"controller": {
|
||||
"status": "ready" if broker_ready else "unavailable",
|
||||
"reason": (
|
||||
""
|
||||
if broker_ready
|
||||
else str(controller_capability.get("reason") or (
|
||||
"trusted_appdata_root_mismatch"
|
||||
if controller_capability.get("status") == "ready" and not controller_root_matches
|
||||
else "deployment_executor_unavailable"
|
||||
))
|
||||
**(
|
||||
{"reason": _safe_capability_reason(controller_managed_storage.get("reason"))}
|
||||
if trust is not None
|
||||
and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT
|
||||
and _safe_capability_reason(controller_managed_storage.get("reason"))
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"enrollmentReadiness": {
|
||||
"status": "ready" if target_enrollment_ready else "unavailable",
|
||||
"reason": enrollment_reason,
|
||||
},
|
||||
"registry": registry_projection,
|
||||
"controller": {
|
||||
"status": "ready" if controller_core_ready else "unavailable",
|
||||
"reason": controller_reason,
|
||||
},
|
||||
"profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")],
|
||||
}
|
||||
|
||||
@@ -433,6 +546,18 @@ class EnrollmentPlane:
|
||||
if by_repository and (not by_application or by_repository.id != by_application.id):
|
||||
checks.append(_check("target_already_exists", "blocked", "Repository is already bound to a different managed application."))
|
||||
|
||||
policy_ids = (
|
||||
f"enrollment-{plan.application_id}-deployment-apply",
|
||||
f"enrollment-{plan.application_id}-deployment-rollback",
|
||||
)
|
||||
policy_collisions = [policy_id for policy_id in policy_ids if session.get(Policy, policy_id) is not None]
|
||||
checks.append(_check(
|
||||
"exact_policy_id_collision",
|
||||
"blocked" if policy_collisions else "passed",
|
||||
"An enrollment-generated exact policy identifier is already owned and cannot be adopted."
|
||||
if policy_collisions else "Both enrollment-generated exact policy identifiers are available.",
|
||||
))
|
||||
|
||||
containers = self.portainer.list_containers()
|
||||
if containers.get("status") != "ok":
|
||||
checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection."))
|
||||
@@ -676,17 +801,25 @@ class EnrollmentPlane:
|
||||
"sourceRevision": plan.revision,
|
||||
"createdAt": utc_now().isoformat(),
|
||||
}
|
||||
identifiers: list[str] = []
|
||||
for action, suffix in (("deployment.apply", "deployment-apply"), ("deployment.rollback", "deployment-rollback")):
|
||||
policy_id = f"enrollment-{plan.application_id}-{suffix}"
|
||||
existing = session.get(Policy, policy_id)
|
||||
if existing is not None:
|
||||
# Enrollment never adopts or repurposes a pre-existing policy,
|
||||
# even where its visible action/target happen to match. A
|
||||
# replay is handled before this method; this branch is a
|
||||
# collision, not a safe recovery path.
|
||||
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
|
||||
existing = Policy(
|
||||
policy_specs = (
|
||||
(
|
||||
"deployment.apply",
|
||||
f"enrollment-{plan.application_id}-deployment-apply",
|
||||
),
|
||||
(
|
||||
"deployment.rollback",
|
||||
f"enrollment-{plan.application_id}-deployment-rollback",
|
||||
),
|
||||
)
|
||||
identifiers = [policy_id for _action, policy_id in policy_specs]
|
||||
# Check the complete pair before adding either row. The database flush
|
||||
# below is one transaction so a concurrent uniqueness collision cannot
|
||||
# leave the first insert committed by _fail().
|
||||
if any(session.get(Policy, policy_id) is not None for policy_id in identifiers):
|
||||
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
|
||||
|
||||
for action, policy_id in policy_specs:
|
||||
policy = Policy(
|
||||
id=policy_id,
|
||||
action=action,
|
||||
target=plan.application_id,
|
||||
@@ -695,10 +828,17 @@ class EnrollmentPlane:
|
||||
risk="high",
|
||||
reason="Enrollment-generated exact-target deployment policy.",
|
||||
)
|
||||
existing.provenance_json = provenance
|
||||
session.add(existing)
|
||||
identifiers.append(policy_id)
|
||||
session.commit()
|
||||
policy.provenance_json = provenance
|
||||
session.add(policy)
|
||||
try:
|
||||
# Force uniqueness errors inside this boundary; on failure discard
|
||||
# the uncommitted unit of work before _fail() persists its outcome.
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted") from exc
|
||||
# Deliberately do not commit here: the caller commits these rows together
|
||||
# with the enrollment/job ready state after policy installation succeeds.
|
||||
return identifiers
|
||||
|
||||
def _compensate(
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.nginx_proxy_manager import (
|
||||
NginxProxyManagerConnector,
|
||||
_NpmForgeRoutePermissionDenied,
|
||||
)
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..db.models import DeploymentJob, Policy, Secret
|
||||
from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1
|
||||
from ..schemas.forge_route_stage import ForgeRouteStageResultV1
|
||||
from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessResultV1
|
||||
from .audit_logger import log_event
|
||||
from .forge_bootstrap_apply import (
|
||||
ForgeBootstrapApplyService,
|
||||
_classify_controller_result,
|
||||
_sha256,
|
||||
)
|
||||
from .forge_oidc_provisioning import _forge_oidc_process_lock
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret, secret_storage_status, set_secret
|
||||
|
||||
_ACTION = "forge.route.stage"
|
||||
_TARGET = "itworx-forge"
|
||||
_REPOSITORY = "jens/itworx-forge"
|
||||
_JOB_ID = re.compile(r"^forge-install-[0-9a-f]{64}$")
|
||||
_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
_OWNERSHIP_ID = re.compile(r"^froute-[0-9a-f]{32}$")
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
_DOMAIN_LABEL = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
_ROUTE_HOST = "forge.example.com"
|
||||
_ROUTE_NETWORK = "itworx-mcp-hub-npm"
|
||||
_ROUTE_UPSTREAM = "http://itworx-forge:8080"
|
||||
_INTENT_VERSION = "itworx.forge-route-stage-intent/v1"
|
||||
_INTENT_PHASES = {"pending", "outcome_unknown", "host_staged_disabled", "aborted_before_post"}
|
||||
_READINESS_SERVICES = ("api", "dashboard", "itworx-forge", "postgres", "worker")
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate journal key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def _reject_json_constant(_constant: str) -> Any:
|
||||
raise ValueError("invalid journal number")
|
||||
|
||||
|
||||
def _readiness_is_fresh(identity: dict[str, Any]) -> bool:
|
||||
observed_at = identity.get("observedAt")
|
||||
if type(observed_at) is not int:
|
||||
return False
|
||||
now = int(time.time())
|
||||
return now - 30 <= observed_at <= now + 5
|
||||
|
||||
|
||||
def _journal_secret_ref(job_id: str) -> str:
|
||||
# set_secret normalizes reference names to uppercase before persistence.
|
||||
return ("FORGE_ROUTE_STAGE_" + hashlib.sha256(job_id.encode("ascii")).hexdigest()).upper()
|
||||
|
||||
|
||||
def _secret_store_ready() -> bool:
|
||||
try:
|
||||
status = secret_storage_status()
|
||||
except Exception: # noqa: BLE001 - secret-store diagnostics never enter route results.
|
||||
return False
|
||||
return isinstance(status, dict) and status.get("productionReady") is True
|
||||
|
||||
|
||||
def _exact_route_policy_allowed(session: Session) -> str | None:
|
||||
rows = session.exec(select(Policy).where(Policy.action == _ACTION)).all()
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
policy = rows[0]
|
||||
if (
|
||||
policy.target != _TARGET
|
||||
or policy.manual != "allowed"
|
||||
or policy.voice != "blocked"
|
||||
or policy.risk != "critical"
|
||||
):
|
||||
return None
|
||||
decision = evaluate_policy(session, _ACTION, _TARGET, "manual")
|
||||
if (
|
||||
decision.get("decision") != "allowed"
|
||||
or decision.get("scope_decision") != "allowed"
|
||||
or decision.get("policy_id") != policy.id
|
||||
):
|
||||
return None
|
||||
return policy.id
|
||||
|
||||
|
||||
def _result(job_id: str, status: str, message: str) -> dict[str, Any]:
|
||||
staged = status == "host_staged_disabled"
|
||||
return ForgeRouteStageResultV1.model_validate(
|
||||
{
|
||||
"success": staged,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"target": _TARGET,
|
||||
"jobId": job_id,
|
||||
"hostname": _ROUTE_HOST,
|
||||
"upstream": _ROUTE_UPSTREAM,
|
||||
"routeState": "host_staged_disabled" if staged else "not_routed",
|
||||
"routed": False,
|
||||
}
|
||||
).model_dump(mode="json", by_alias=True)
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
*,
|
||||
actor_identity: str,
|
||||
decision: str,
|
||||
policy_id: str | None,
|
||||
result: dict[str, Any],
|
||||
idempotency_hash: str,
|
||||
) -> bool:
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
source="manual",
|
||||
identity=actor_identity,
|
||||
action_id=_ACTION,
|
||||
target=_TARGET,
|
||||
target_type="forge_route_stage",
|
||||
risk="critical",
|
||||
policy_decision=decision,
|
||||
reason=result["message"],
|
||||
result=result["status"],
|
||||
after_state={
|
||||
"status": result["status"],
|
||||
"routeState": result["routeState"],
|
||||
"routed": False,
|
||||
},
|
||||
redacted_metadata={
|
||||
"policyId": policy_id,
|
||||
"idempotencyKeySha256": idempotency_hash,
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - audit/DB failures never expose held connector data.
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001 - keep DB details out of the response.
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _verified_job(session: Session, job_id: str) -> tuple[DeploymentJob, ForgeBootstrapApplyV1] | None:
|
||||
if _JOB_ID.fullmatch(job_id) is None:
|
||||
return None
|
||||
try:
|
||||
job = session.get(DeploymentJob, job_id)
|
||||
if (
|
||||
job is None
|
||||
or job.application_id != _TARGET
|
||||
or job.repository != _REPOSITORY
|
||||
or job.status != "deployed"
|
||||
or job.stage not in {"controller_deployed", "resume_controller_deployed"}
|
||||
or job.completed_at is None
|
||||
or not isinstance(job.broker_request_id, str)
|
||||
or _SHA256.fullmatch(job.broker_request_id) is None
|
||||
or not isinstance(job.plan_json, dict)
|
||||
or _sha256(job.plan_json) != job.plan_sha256
|
||||
):
|
||||
return None
|
||||
profile = ForgeBootstrapApplyV1.model_validate(job.plan_json)
|
||||
preflight_receipt = job.preflight_json
|
||||
if (
|
||||
job.revision != profile.revision
|
||||
or not isinstance(preflight_receipt, dict)
|
||||
or set(preflight_receipt) != {"apiVersion", "jobId", "sha256"}
|
||||
or preflight_receipt
|
||||
!= {
|
||||
"apiVersion": "itworx.forge-bootstrap-preflight/v1",
|
||||
"jobId": profile.preflight_job_id,
|
||||
"sha256": profile.preflight_sha256,
|
||||
}
|
||||
):
|
||||
return None
|
||||
evidence = job.evidence_json
|
||||
if (
|
||||
not isinstance(evidence, dict)
|
||||
or set(evidence) != {"profile", "controller"}
|
||||
or evidence.get("profile") != "forge-first-install"
|
||||
):
|
||||
return None
|
||||
historical_status, _stage, _summary, _safe_evidence = _classify_controller_result(
|
||||
evidence.get("controller"),
|
||||
request_id=job.broker_request_id,
|
||||
profile=profile,
|
||||
)
|
||||
if historical_status != "deployed":
|
||||
return None
|
||||
return job, profile
|
||||
except Exception: # noqa: BLE001 - invalid stored job data is a fixed denial.
|
||||
return None
|
||||
|
||||
|
||||
def _readiness_identity(
|
||||
value: object,
|
||||
*,
|
||||
job: DeploymentJob,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
) -> dict[str, Any] | None:
|
||||
try:
|
||||
readiness = ForgeRuntimeReadinessResultV1.model_validate(value)
|
||||
except Exception: # noqa: BLE001 - the current readiness projection is untrusted input.
|
||||
return None
|
||||
now = int(time.time())
|
||||
artifact = readiness.controller_artifact
|
||||
if (
|
||||
readiness.status != "ready"
|
||||
or readiness.target != _TARGET
|
||||
or readiness.job_id != job.id
|
||||
or readiness.revision != profile.revision
|
||||
or readiness.workspace_image_digest != profile.workspace_image_digest
|
||||
or readiness.frontdoor_image_digest != profile.frontdoor_image_digest
|
||||
or type(readiness.observed_at) is not int
|
||||
or readiness.observed_at < now - 30
|
||||
or readiness.observed_at > now + 5
|
||||
or readiness.migration_completed is not True
|
||||
or readiness.health.status != "healthy"
|
||||
or tuple(item.name for item in readiness.health.services) != _READINESS_SERVICES
|
||||
or any(item.status != "healthy" for item in readiness.health.services)
|
||||
or artifact is None
|
||||
or artifact.source_repository != "jens/unraid-appops-gateway"
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", artifact.source_revision)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", artifact.source_tree)
|
||||
or not _SHA256.fullmatch(artifact.artifact_sha256)
|
||||
):
|
||||
return None
|
||||
identity = {
|
||||
"jobId": job.id,
|
||||
"observedAt": readiness.observed_at,
|
||||
"revision": profile.revision,
|
||||
"workspaceImageDigest": profile.workspace_image_digest,
|
||||
"frontdoorImageDigest": profile.frontdoor_image_digest,
|
||||
"migrationCompleted": True,
|
||||
"healthServices": list(_READINESS_SERVICES),
|
||||
"controllerArtifact": {
|
||||
"sourceRepository": artifact.source_repository,
|
||||
"sourceRevision": artifact.source_revision,
|
||||
"sourceTree": artifact.source_tree,
|
||||
"artifactSha256": artifact.artifact_sha256,
|
||||
},
|
||||
}
|
||||
return {"sha256": hashlib.sha256(_canonical_json(identity)).hexdigest(), **identity}
|
||||
|
||||
|
||||
def _network_ready(value: object) -> bool:
|
||||
return bool(
|
||||
isinstance(value, dict)
|
||||
and set(value) == {"status", "network", "npmAttached", "frontdoorAttached"}
|
||||
and value.get("status") == "ready"
|
||||
and value.get("network") == _ROUTE_NETWORK
|
||||
and value.get("npmAttached") is True
|
||||
and value.get("frontdoorAttached") is True
|
||||
)
|
||||
|
||||
|
||||
def _forge_route_npm_session(npm: NginxProxyManagerConnector) -> Any | None:
|
||||
"""Require the connector's fixed route-only HTTPS transport contract."""
|
||||
factory = getattr(npm, "forge_route_session", None)
|
||||
if not callable(factory):
|
||||
return None
|
||||
try:
|
||||
route_session = factory()
|
||||
except Exception: # noqa: BLE001 - connector configuration is private.
|
||||
return None
|
||||
endpoint_sha256 = getattr(route_session, "endpoint_sha256", None)
|
||||
if (
|
||||
getattr(route_session, "verified_tls", None) is not True
|
||||
or not isinstance(endpoint_sha256, str)
|
||||
or _SHA256.fullmatch(endpoint_sha256) is None
|
||||
):
|
||||
return None
|
||||
return route_session
|
||||
|
||||
|
||||
def _desired_state(ownership_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"domain_names": [_ROUTE_HOST],
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "itworx-forge",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": 0,
|
||||
"certificate_id": 0,
|
||||
"enabled": False,
|
||||
"ssl_forced": False,
|
||||
"http2_support": False,
|
||||
"hsts_enabled": False,
|
||||
"hsts_subdomains": False,
|
||||
"caching_enabled": False,
|
||||
"block_exploits": True,
|
||||
"allow_websocket_upgrade": False,
|
||||
"trust_forwarded_proto": False,
|
||||
"locations": [],
|
||||
"meta": {"appops_forge_route_id": ownership_id},
|
||||
"advanced_config": "",
|
||||
}
|
||||
|
||||
|
||||
def _build_record(
|
||||
*,
|
||||
job: DeploymentJob,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
preflight_receipt: dict[str, Any],
|
||||
idempotency_hash: str,
|
||||
readiness: dict[str, Any],
|
||||
ownership_id: str,
|
||||
npm_endpoint_sha256: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"apiVersion": _INTENT_VERSION,
|
||||
"jobId": job.id,
|
||||
"idempotencyKeySha256": idempotency_hash,
|
||||
"npmEndpointSha256": npm_endpoint_sha256,
|
||||
"ownershipId": ownership_id,
|
||||
"phase": "pending",
|
||||
"target": _TARGET,
|
||||
"repository": _REPOSITORY,
|
||||
"revision": profile.revision,
|
||||
"workspaceImageDigest": profile.workspace_image_digest,
|
||||
"frontdoorImageDigest": profile.frontdoor_image_digest,
|
||||
"preflightReceiptSha256": preflight_receipt["sha256"],
|
||||
"readinessIdentity": readiness,
|
||||
"desired": _desired_state(ownership_id),
|
||||
}
|
||||
|
||||
|
||||
def _record_valid(value: object, job_id: str) -> bool:
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"apiVersion",
|
||||
"jobId",
|
||||
"idempotencyKeySha256",
|
||||
"npmEndpointSha256",
|
||||
"ownershipId",
|
||||
"phase",
|
||||
"target",
|
||||
"repository",
|
||||
"revision",
|
||||
"workspaceImageDigest",
|
||||
"frontdoorImageDigest",
|
||||
"preflightReceiptSha256",
|
||||
"readinessIdentity",
|
||||
"desired",
|
||||
}:
|
||||
return False
|
||||
owner = value.get("ownershipId")
|
||||
identity = value.get("readinessIdentity")
|
||||
if (
|
||||
value.get("apiVersion") != _INTENT_VERSION
|
||||
or value.get("jobId") != job_id
|
||||
or not isinstance(value.get("idempotencyKeySha256"), str)
|
||||
or _SHA256.fullmatch(value["idempotencyKeySha256"]) is None
|
||||
or not isinstance(value.get("npmEndpointSha256"), str)
|
||||
or _SHA256.fullmatch(value["npmEndpointSha256"]) is None
|
||||
or not isinstance(owner, str)
|
||||
or _OWNERSHIP_ID.fullmatch(owner) is None
|
||||
or value.get("phase") not in _INTENT_PHASES
|
||||
or value.get("target") != _TARGET
|
||||
or value.get("repository") != _REPOSITORY
|
||||
or not isinstance(value.get("revision"), str)
|
||||
or re.fullmatch(r"[0-9a-f]{40}", value["revision"]) is None
|
||||
or not isinstance(value.get("workspaceImageDigest"), str)
|
||||
or _DIGEST.fullmatch(value["workspaceImageDigest"]) is None
|
||||
or not isinstance(value.get("frontdoorImageDigest"), str)
|
||||
or _DIGEST.fullmatch(value["frontdoorImageDigest"]) is None
|
||||
or not isinstance(value.get("preflightReceiptSha256"), str)
|
||||
or _SHA256.fullmatch(value["preflightReceiptSha256"]) is None
|
||||
or not isinstance(identity, dict)
|
||||
or set(identity)
|
||||
!= {
|
||||
"sha256",
|
||||
"jobId",
|
||||
"observedAt",
|
||||
"revision",
|
||||
"workspaceImageDigest",
|
||||
"frontdoorImageDigest",
|
||||
"migrationCompleted",
|
||||
"healthServices",
|
||||
"controllerArtifact",
|
||||
}
|
||||
or not isinstance(identity.get("sha256"), str)
|
||||
or _SHA256.fullmatch(identity["sha256"]) is None
|
||||
or identity.get("jobId") != job_id
|
||||
or type(identity.get("observedAt")) is not int
|
||||
or identity.get("revision") != value.get("revision")
|
||||
or identity.get("workspaceImageDigest") != value.get("workspaceImageDigest")
|
||||
or identity.get("frontdoorImageDigest") != value.get("frontdoorImageDigest")
|
||||
or identity.get("migrationCompleted") is not True
|
||||
or identity.get("healthServices") != list(_READINESS_SERVICES)
|
||||
or not isinstance(identity.get("controllerArtifact"), dict)
|
||||
or not isinstance(value.get("desired"), dict)
|
||||
or value["desired"] != _desired_state(owner)
|
||||
):
|
||||
return False
|
||||
if hashlib.sha256(
|
||||
_canonical_json({key: item for key, item in identity.items() if key != "sha256"})
|
||||
).hexdigest() != identity["sha256"]:
|
||||
return False
|
||||
artifact = identity["controllerArtifact"]
|
||||
return bool(
|
||||
set(artifact)
|
||||
== {"sourceRepository", "sourceRevision", "sourceTree", "artifactSha256"}
|
||||
and artifact.get("sourceRepository") == "jens/unraid-appops-gateway"
|
||||
and isinstance(artifact.get("sourceRevision"), str)
|
||||
and re.fullmatch(r"[0-9a-f]{40}", artifact["sourceRevision"]) is not None
|
||||
and isinstance(artifact.get("sourceTree"), str)
|
||||
and re.fullmatch(r"[0-9a-f]{40}", artifact["sourceTree"])
|
||||
and isinstance(artifact.get("artifactSha256"), str)
|
||||
and _SHA256.fullmatch(artifact["artifactSha256"]) is not None
|
||||
)
|
||||
|
||||
|
||||
def _load_record(session: Session, job_id: str) -> tuple[str, dict[str, Any] | None]:
|
||||
secret_ref = _journal_secret_ref(job_id)
|
||||
row = session.get(Secret, secret_ref)
|
||||
if row is None:
|
||||
return "missing", None
|
||||
raw = get_secret(session, secret_ref, touch=False)
|
||||
if not isinstance(raw, str) or len(raw) > 16_384:
|
||||
return "invalid", None
|
||||
try:
|
||||
value = json.loads(
|
||||
raw,
|
||||
object_pairs_hook=_reject_duplicate_json_keys,
|
||||
parse_constant=_reject_json_constant,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return "invalid", None
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or _canonical_json(value).decode("utf-8") != raw
|
||||
or not _record_valid(value, job_id)
|
||||
):
|
||||
return "invalid", None
|
||||
return "present", value
|
||||
|
||||
|
||||
def _save_record(session: Session, record: dict[str, Any], *, commit: bool = True) -> bool:
|
||||
try:
|
||||
if not _record_valid(record, record.get("jobId", "")):
|
||||
return False
|
||||
set_secret(
|
||||
session,
|
||||
_journal_secret_ref(record["jobId"]),
|
||||
_canonical_json(record).decode("utf-8"),
|
||||
used_by=[_ACTION],
|
||||
metadata={"kind": "encrypted_forge_route_stage_intent"},
|
||||
commit=commit,
|
||||
)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - secret-store/database errors remain fixed failures.
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _normalized_domain(value: object) -> str | None:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or len(value) > 254
|
||||
or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value)
|
||||
):
|
||||
return None
|
||||
domain = value[:-1] if value.endswith(".") else value
|
||||
if not domain or domain.endswith("."):
|
||||
return None
|
||||
wildcard = domain.startswith("*.")
|
||||
if "*" in domain and not wildcard:
|
||||
return None
|
||||
suffix = domain[2:] if wildcard else domain
|
||||
try:
|
||||
ascii_domain = suffix.encode("idna").decode("ascii").lower()
|
||||
except UnicodeError:
|
||||
return None
|
||||
labels = ascii_domain.split(".")
|
||||
if (
|
||||
not ascii_domain
|
||||
or len(ascii_domain) > 253
|
||||
or any(not label or len(label) > 63 or _DOMAIN_LABEL.fullmatch(label) is None for label in labels)
|
||||
):
|
||||
return None
|
||||
return f"*.{ascii_domain}" if wildcard else ascii_domain
|
||||
|
||||
|
||||
def _domain_matches(value: str) -> bool:
|
||||
normalized = _normalized_domain(value)
|
||||
if normalized == _ROUTE_HOST:
|
||||
return True
|
||||
if normalized is not None and normalized.startswith("*."):
|
||||
suffix = normalized[2:]
|
||||
# Treat any ancestor wildcard as a possible NPM match, including broad
|
||||
# patterns such as *.tech, so route creation fails closed on overlap.
|
||||
return _ROUTE_HOST.endswith(f".{suffix}")
|
||||
return False
|
||||
|
||||
|
||||
def _inventory_matches(value: object) -> tuple[str, list[dict[str, Any]]]:
|
||||
if not isinstance(value, list):
|
||||
return "unknown", []
|
||||
matches: list[dict[str, Any]] = []
|
||||
for host in value:
|
||||
if not isinstance(host, dict):
|
||||
return "unknown", []
|
||||
domains = host.get("domain_names")
|
||||
if not isinstance(domains, list) or any(_normalized_domain(domain) is None for domain in domains):
|
||||
return "unknown", []
|
||||
if any(_domain_matches(domain) for domain in domains):
|
||||
matches.append(host)
|
||||
return "ok", matches
|
||||
|
||||
|
||||
def _host_is_exact_disabled(host: dict[str, Any], record: dict[str, Any]) -> bool:
|
||||
expected = record["desired"]
|
||||
for field in (
|
||||
"forward_scheme",
|
||||
"forward_host",
|
||||
"advanced_config",
|
||||
):
|
||||
if type(host.get(field)) is not str or host[field] != expected[field]:
|
||||
return False
|
||||
for field in (
|
||||
"forward_port",
|
||||
"access_list_id",
|
||||
"certificate_id",
|
||||
):
|
||||
if type(host.get(field)) is not int or host[field] != expected[field]:
|
||||
return False
|
||||
for field in (
|
||||
"enabled",
|
||||
"ssl_forced",
|
||||
"http2_support",
|
||||
"hsts_enabled",
|
||||
"hsts_subdomains",
|
||||
"caching_enabled",
|
||||
"block_exploits",
|
||||
"allow_websocket_upgrade",
|
||||
"trust_forwarded_proto",
|
||||
):
|
||||
if type(host.get(field)) is not bool or host[field] is not expected[field]:
|
||||
return False
|
||||
host_id = host.get("id")
|
||||
if type(host_id) is not int or host_id < 1:
|
||||
return False
|
||||
meta = host.get("meta")
|
||||
return bool(
|
||||
host.get("domain_names") == [_ROUTE_HOST]
|
||||
and isinstance(meta, dict)
|
||||
and meta.get("appops_forge_route_id") == record["ownershipId"]
|
||||
and type(host.get("locations")) is list
|
||||
and host["locations"] == []
|
||||
)
|
||||
|
||||
|
||||
def _inspect_inventory(value: object, record: dict[str, Any] | None) -> str:
|
||||
status, matches = _inventory_matches(value)
|
||||
if status != "ok":
|
||||
return "unknown"
|
||||
if len(matches) > 1:
|
||||
return "conflict"
|
||||
if not matches:
|
||||
return "missing"
|
||||
if record is None:
|
||||
return "conflict"
|
||||
host = matches[0]
|
||||
meta = host.get("meta")
|
||||
if not isinstance(meta, dict) or meta.get("appops_forge_route_id") != record["ownershipId"]:
|
||||
return "conflict"
|
||||
return "staged" if _host_is_exact_disabled(host, record) else "conflict"
|
||||
|
||||
|
||||
def _reconcile_locked(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
record: dict[str, Any],
|
||||
npm: NginxProxyManagerConnector,
|
||||
defer_phase_write: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
endpoint_sha256 = getattr(npm, "endpoint_sha256", None)
|
||||
if (
|
||||
getattr(npm, "verified_tls", None) is not True
|
||||
or not isinstance(endpoint_sha256, str)
|
||||
or _SHA256.fullmatch(endpoint_sha256) is None
|
||||
):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
if endpoint_sha256 != record.get("npmEndpointSha256"):
|
||||
return _result(job_id, "conflict", "forge_route_endpoint_binding_changed")
|
||||
try:
|
||||
hosts = npm.list_forge_route_hosts()
|
||||
except Exception: # noqa: BLE001 - do not expose NPM responses, URLs or credentials.
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
state = _inspect_inventory(hosts, record)
|
||||
if state == "staged":
|
||||
if record.get("phase") != "host_staged_disabled":
|
||||
record["phase"] = "host_staged_disabled"
|
||||
if not defer_phase_write and not _save_record(session, record):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_journal_update_failed")
|
||||
return _result(job_id, "host_staged_disabled", "forge_route_host_staged_disabled_not_routed")
|
||||
if state == "conflict":
|
||||
return _result(job_id, "conflict", "forge_route_ownership_or_state_conflict")
|
||||
if state == "missing" and record.get("phase") == "aborted_before_post":
|
||||
return _result(job_id, "blocked", "forge_route_policy_changed_before_mutation")
|
||||
return _result(job_id, "outcome_unknown", "forge_route_host_state_unconfirmed")
|
||||
|
||||
|
||||
def stage_forge_route(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
idempotency_key: str,
|
||||
actor_identity: str,
|
||||
npm: NginxProxyManagerConnector | None = None,
|
||||
portainer: PortainerConnector | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Stage one disabled, fixed Forge host after current readiness is proven."""
|
||||
normalized_key = idempotency_key.strip() if isinstance(idempotency_key, str) else ""
|
||||
idempotency_hash = hashlib.sha256(normalized_key.encode("utf-8")).hexdigest()
|
||||
actor = actor_identity.strip() if isinstance(actor_identity, str) else ""
|
||||
connector = npm or NginxProxyManagerConnector()
|
||||
network_reader = portainer or PortainerConnector()
|
||||
policy_id: str | None = None
|
||||
decision = "blocked"
|
||||
mutation_attempted = False
|
||||
outcome = _result(job_id, "blocked", "forge_route_stage_requirements_not_met")
|
||||
try:
|
||||
if _JOB_ID.fullmatch(job_id) is None or _IDEMPOTENCY.fullmatch(normalized_key) is None or not actor:
|
||||
outcome = _result(job_id, "blocked", "forge_route_request_invalid")
|
||||
else:
|
||||
with _forge_oidc_process_lock():
|
||||
policy_id = _exact_route_policy_allowed(session)
|
||||
if policy_id is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required")
|
||||
else:
|
||||
decision = "allowed"
|
||||
record_state, record = _load_record(session, job_id)
|
||||
if record_state == "invalid":
|
||||
outcome = _result(job_id, "conflict", "forge_route_journal_invalid")
|
||||
elif record_state == "present" and record is not None:
|
||||
if record["idempotencyKeySha256"] != idempotency_hash:
|
||||
outcome = _result(job_id, "conflict", "forge_route_job_already_claimed")
|
||||
else:
|
||||
route_npm = _forge_route_npm_session(connector)
|
||||
if route_npm is None:
|
||||
outcome = _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
else:
|
||||
outcome = _reconcile_locked(
|
||||
session, job_id=job_id, record=record, npm=route_npm
|
||||
)
|
||||
else:
|
||||
verified = _verified_job(session, job_id)
|
||||
if verified is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_successful_job_required")
|
||||
elif not _secret_store_ready():
|
||||
outcome = _result(job_id, "blocked", "forge_route_encrypted_journal_required")
|
||||
else:
|
||||
job, profile = verified
|
||||
route_npm = _forge_route_npm_session(connector)
|
||||
if route_npm is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_verified_npm_tls_required")
|
||||
else:
|
||||
try:
|
||||
initial_hosts = route_npm.list_forge_route_hosts()
|
||||
except _NpmForgeRoutePermissionDenied:
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"blocked",
|
||||
"forge_route_npm_permission_required",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - no connector diagnostics escape.
|
||||
outcome = _result(job_id, "failed", "forge_route_npm_read_unavailable")
|
||||
else:
|
||||
inventory_state, matches = _inventory_matches(initial_hosts)
|
||||
if inventory_state != "ok":
|
||||
outcome = _result(job_id, "failed", "forge_route_npm_inventory_invalid")
|
||||
elif matches:
|
||||
outcome = _result(job_id, "conflict", "forge_route_existing_host_conflict")
|
||||
elif not _network_ready(network_reader.forge_route_network_readiness()):
|
||||
outcome = _result(job_id, "blocked", "forge_route_shared_network_not_ready")
|
||||
else:
|
||||
readiness_value = ForgeBootstrapApplyService().current_readiness(
|
||||
session, job_id
|
||||
)
|
||||
readiness = _readiness_identity(
|
||||
readiness_value, job=job, profile=profile
|
||||
)
|
||||
if readiness is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_current_readiness_required")
|
||||
else:
|
||||
# Last authorization check occurs in the existing Forge lock,
|
||||
# after fresh readiness and immediately before the journal/POST.
|
||||
if _exact_route_policy_allowed(session) != policy_id:
|
||||
outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required")
|
||||
else:
|
||||
ownership_id = "froute-" + uuid4().hex
|
||||
record = _build_record(
|
||||
job=job,
|
||||
profile=profile,
|
||||
preflight_receipt=job.preflight_json,
|
||||
idempotency_hash=idempotency_hash,
|
||||
readiness=readiness,
|
||||
ownership_id=ownership_id,
|
||||
npm_endpoint_sha256=route_npm.endpoint_sha256,
|
||||
)
|
||||
if not _save_record(session, record):
|
||||
outcome = _result(job_id, "failed", "forge_route_journal_persist_failed")
|
||||
elif _exact_route_policy_allowed(session) != policy_id:
|
||||
record["phase"] = "aborted_before_post"
|
||||
_save_record(session, record)
|
||||
outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required")
|
||||
elif not _readiness_is_fresh(record["readinessIdentity"]):
|
||||
record["phase"] = "aborted_before_post"
|
||||
_save_record(session, record)
|
||||
outcome = _result(job_id, "blocked", "forge_route_current_readiness_expired")
|
||||
else:
|
||||
mutation_attempted = True
|
||||
try:
|
||||
route_npm.create_forge_disabled_proxy_host(ownership_id)
|
||||
except _NpmForgeRoutePermissionDenied:
|
||||
mutation_attempted = False
|
||||
record["phase"] = "aborted_before_post"
|
||||
if _save_record(session, record):
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"blocked",
|
||||
"forge_route_npm_permission_required",
|
||||
)
|
||||
else:
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"outcome_unknown",
|
||||
"forge_route_journal_update_failed",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a failed POST is ambiguous.
|
||||
record["phase"] = "outcome_unknown"
|
||||
_save_record(session, record)
|
||||
if mutation_attempted:
|
||||
outcome = _reconcile_locked(
|
||||
session,
|
||||
job_id=job_id,
|
||||
record=record,
|
||||
npm=route_npm,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - no lock, connector, or DB detail reaches the caller.
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"outcome_unknown" if mutation_attempted else "failed",
|
||||
"forge_route_operation_unavailable",
|
||||
)
|
||||
|
||||
if not _audit(
|
||||
session,
|
||||
actor_identity=actor or "manual",
|
||||
decision=decision,
|
||||
policy_id=policy_id,
|
||||
result=outcome,
|
||||
idempotency_hash=idempotency_hash,
|
||||
):
|
||||
return _result(
|
||||
job_id,
|
||||
"outcome_unknown" if mutation_attempted else "failed",
|
||||
"forge_route_audit_persist_failed",
|
||||
)
|
||||
return outcome
|
||||
|
||||
|
||||
def reconcile_forge_route(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
actor_identity: str,
|
||||
npm: NginxProxyManagerConnector | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read back the fixed host for an existing operation; this function never POSTs."""
|
||||
connector = npm or NginxProxyManagerConnector()
|
||||
actor = actor_identity.strip() if isinstance(actor_identity, str) else ""
|
||||
if _JOB_ID.fullmatch(job_id) is None:
|
||||
return _result(job_id, "not_found", "forge_route_operation_not_found")
|
||||
if not actor:
|
||||
return _result(job_id, "failed", "forge_route_reconcile_unavailable")
|
||||
try:
|
||||
with _forge_oidc_process_lock():
|
||||
state, record = _load_record(session, job_id)
|
||||
if state == "missing":
|
||||
return _result(job_id, "not_found", "forge_route_operation_not_found")
|
||||
if state != "present" or record is None:
|
||||
return _result(job_id, "outcome_unknown", "forge_route_journal_invalid")
|
||||
route_npm = _forge_route_npm_session(connector)
|
||||
if route_npm is None:
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
previous_phase = record["phase"]
|
||||
result = _reconcile_locked(
|
||||
session,
|
||||
job_id=job_id,
|
||||
record=record,
|
||||
npm=route_npm,
|
||||
defer_phase_write=True,
|
||||
)
|
||||
if result["status"] == "host_staged_disabled" and previous_phase != record["phase"]:
|
||||
if not _save_record(session, record, commit=False):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_journal_update_failed")
|
||||
if not _audit(
|
||||
session,
|
||||
actor_identity=actor,
|
||||
decision="allowed",
|
||||
policy_id=None,
|
||||
result=result,
|
||||
idempotency_hash=record["idempotencyKeySha256"],
|
||||
):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_audit_persist_failed")
|
||||
return result
|
||||
except Exception: # noqa: BLE001 - status remains safe when stores/connectors fail.
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
@@ -28,6 +28,7 @@ _COMPOSE_CANDIDATES = (
|
||||
)
|
||||
# Must remain identical to DeploymentPlanV1.application_id.
|
||||
_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||
_COMPOSE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
_TRANSIENT_STATES = {"gitea_unavailable", "controller_unavailable", "registration_error"}
|
||||
_RETRY_STATES = _TRANSIENT_STATES | {"controller_conflict", "policy_conflict"}
|
||||
_DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback", "deployment.runnerless.provision")
|
||||
@@ -208,6 +209,7 @@ class GiteaProjectAutoRegistration:
|
||||
"composeFile",
|
||||
"composeProject",
|
||||
"healthTimeout",
|
||||
"primaryService",
|
||||
"verifyOciRevision",
|
||||
}
|
||||
unknown = set(payload) - allowed
|
||||
@@ -223,14 +225,18 @@ class GiteaProjectAutoRegistration:
|
||||
raise ValueError("deployment descriptor applicationId is invalid")
|
||||
compose_file = str(payload.get("composeFile") or "").strip()
|
||||
compose_project = str(payload.get("composeProject") or application_id).strip().lower()
|
||||
if compose_project != application_id:
|
||||
raise ValueError("deployment descriptor composeProject must equal applicationId")
|
||||
if not _COMPOSE_PROJECT.fullmatch(compose_project):
|
||||
raise ValueError("deployment descriptor composeProject is invalid")
|
||||
primary_service = str(payload.get("primaryService") or application_id).strip()
|
||||
if not _APP_ID.fullmatch(primary_service):
|
||||
raise ValueError("deployment descriptor primaryService is invalid")
|
||||
timeout = int(payload.get("healthTimeout") or 180)
|
||||
return {
|
||||
"applicationId": application_id,
|
||||
"composeFile": compose_file,
|
||||
"composeProject": compose_project,
|
||||
"healthTimeout": timeout,
|
||||
"primaryService": primary_service,
|
||||
"verifyOciRevision": bool(payload.get("verifyOciRevision", False)),
|
||||
}
|
||||
|
||||
@@ -303,8 +309,9 @@ class GiteaProjectAutoRegistration:
|
||||
|
||||
if descriptor_present:
|
||||
application_id = str(descriptor["applicationId"])
|
||||
if application_id not in service_names:
|
||||
raise ValueError("deployment descriptor applicationId must name a Compose service")
|
||||
primary_service = str(descriptor["primaryService"])
|
||||
if primary_service not in service_names:
|
||||
raise ValueError("deployment descriptor primaryService must name a Compose service")
|
||||
else:
|
||||
repository_id = self._application_id(repository_name)
|
||||
compose_name = str(compose.get("name") or "").strip().lower() if isinstance(compose, dict) else ""
|
||||
@@ -321,8 +328,9 @@ class GiteaProjectAutoRegistration:
|
||||
# explicit bounded descriptor to select its primary service.
|
||||
return None
|
||||
application_id = candidates[0]
|
||||
primary_service = application_id
|
||||
|
||||
compose_project = application_id
|
||||
compose_project = str(descriptor.get("composeProject") or application_id)
|
||||
containers: list[str] = []
|
||||
for service_name, raw_service in services.items():
|
||||
service = raw_service if isinstance(raw_service, dict) else {}
|
||||
@@ -334,6 +342,7 @@ class GiteaProjectAutoRegistration:
|
||||
"applicationId": application_id,
|
||||
"composeFile": compose_file,
|
||||
"composeProject": compose_project,
|
||||
"primaryService": primary_service,
|
||||
"containers": containers,
|
||||
"serviceNames": service_names,
|
||||
"healthTimeout": int(descriptor.get("healthTimeout") or 180),
|
||||
@@ -510,6 +519,7 @@ class GiteaProjectAutoRegistration:
|
||||
"applicationId": application_id,
|
||||
"composeFile": contract["composeFile"],
|
||||
"composeProject": contract["composeProject"],
|
||||
"primaryService": contract.get("primaryService", application_id),
|
||||
"containers": list(contract["containers"]),
|
||||
"serviceNames": list(contract["serviceNames"]),
|
||||
}
|
||||
|
||||
@@ -337,17 +337,33 @@ def fixed_image_reference(service_name, image_ref):
|
||||
)
|
||||
)
|
||||
if service_name == "postgres":
|
||||
return image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a" or (
|
||||
image_ref == "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04"
|
||||
and main_declares_external_image(image_ref)
|
||||
)
|
||||
if image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a":
|
||||
return True
|
||||
if image_ref in {
|
||||
"postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04",
|
||||
(
|
||||
"gitea.example.com/jens/itworx-mcp-hub/postgres:"
|
||||
"wp400-gosu-v2-20261002-a94e0b4d2029@sha256:"
|
||||
"a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73"
|
||||
),
|
||||
}:
|
||||
return main_declares_external_image(image_ref)
|
||||
return False
|
||||
if service_name == "ingestion-clamd":
|
||||
return image_ref == "clamav/clamav:1.4.6@sha256:a5f03c12a79dbe9f6d8a527b6bb1ea053fa8dd061d3738a26897f055ee2d9303"
|
||||
if service_name == "redis":
|
||||
return image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236" or (
|
||||
image_ref == "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48"
|
||||
and main_declares_external_image(image_ref)
|
||||
)
|
||||
if image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236":
|
||||
return True
|
||||
if image_ref in {
|
||||
"redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48",
|
||||
(
|
||||
"gitea.example.com/jens/itworx-mcp-hub/redis:"
|
||||
"wp400-remediation-20261002-4e18f61c@sha256:"
|
||||
"7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2"
|
||||
),
|
||||
}:
|
||||
return main_declares_external_image(image_ref)
|
||||
return False
|
||||
if service_name == "contextforge":
|
||||
return main_declares_contextforge_compat_image(
|
||||
"itworx-mcp-hub-contextforge-1", image_ref
|
||||
|
||||
@@ -187,6 +187,29 @@ def evaluate_policy(
|
||||
"scope_decision": "allowed" if allowed else None,
|
||||
}
|
||||
|
||||
if action == "forge.route.stage":
|
||||
# This narrowly scoped external-route mutation never inherits the
|
||||
# generic manual fallback or a wildcard grant. The row is seeded
|
||||
# blocked and must be explicitly enabled for the one Forge target.
|
||||
policies = session.exec(select(Policy).where(Policy.action == action)).all()
|
||||
policy = policies[0] if len(policies) == 1 else None
|
||||
allowed = bool(
|
||||
source == "manual"
|
||||
and target == "itworx-forge"
|
||||
and policy is not None
|
||||
and policy.target == "itworx-forge"
|
||||
and policy.manual == "allowed"
|
||||
and policy.voice == "blocked"
|
||||
and policy.risk == "critical"
|
||||
)
|
||||
return {
|
||||
"decision": "allowed" if allowed else "blocked",
|
||||
"reason": "Forge route staging requires one exact-target manual policy; voice is always blocked.",
|
||||
"risk": "critical",
|
||||
"policy_id": policy.id if allowed and policy is not None else None,
|
||||
"scope_decision": "allowed" if allowed else None,
|
||||
}
|
||||
|
||||
if action in _DEFAULT_DECISIONS:
|
||||
reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.')
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user