Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8

This commit is contained in:
Public Source Publisher committed 2026-10-07 01:56:22 +00:00
1 parent 820244dbb3
commit 8dd4b40252
52 files changed
+11814 -288

No files matched your search

+88
View File
@@ -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)}
+355 -49
View File
@@ -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)
+195
View File
@@ -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
+2 -2
View File
@@ -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),
+1
View File
@@ -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
View File
@@ -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(
+48
View File
@@ -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
+42 -17
View File
@@ -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(
+4
View File
@@ -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()
+28 -3
View File
@@ -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 {
+174 -34
View File
@@ -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(
+835
View File
@@ -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
+23
View File
@@ -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 {