Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11814
-288
No files matched your search
@@ -1555,7 +1555,11 @@ def _execute_controller_artifact_promote(
|
||||
reason: str,
|
||||
original_policy_decision: str,
|
||||
) -> dict[str, Any]:
|
||||
from .controller_artifact_promoter import ControllerArtifactPromoter
|
||||
from .controller_artifact_promoter import (
|
||||
ControllerArtifactPromoter,
|
||||
helper_failure_diagnostics,
|
||||
safe_helper_diagnostics,
|
||||
)
|
||||
|
||||
action = "controller.artifact.promote"
|
||||
target = "tower/deployment-controller"
|
||||
@@ -1595,12 +1599,43 @@ def _execute_controller_artifact_promote(
|
||||
success = False
|
||||
message = f"{type(exc).__name__}: controller artifact promotion failed"
|
||||
|
||||
diagnostics = None
|
||||
if status == "failed" and result.get("reason") == "controller_promoter_helper_failed":
|
||||
diagnostics = safe_helper_diagnostics(result.get("diagnostics"))
|
||||
if diagnostics is None:
|
||||
diagnostics = helper_failure_diagnostics(result.get("exitCode"))
|
||||
exit_code = diagnostics["exitCode"]
|
||||
exit_code_summary = str(exit_code) if exit_code is not None else "unknown"
|
||||
message = (
|
||||
f"Controller promotion helper failed: {diagnostics['code']}; "
|
||||
f"phase={diagnostics['phase']}; exitCode={exit_code_summary}."
|
||||
)
|
||||
|
||||
completed = complete_job(
|
||||
session,
|
||||
job,
|
||||
status="succeeded" if success else "failed",
|
||||
result_summary=message,
|
||||
)
|
||||
after_state = {
|
||||
"status": status,
|
||||
"revision": result.get("revision"),
|
||||
"controllerVersion": result.get("controllerVersion"),
|
||||
"artifactSha256": result.get("artifactSha256"),
|
||||
}
|
||||
redacted_metadata = {
|
||||
"executor": action,
|
||||
"integration": "mcp" if source == "chatgpt-mcp" else "operator",
|
||||
"originalPolicyDecision": original_policy_decision,
|
||||
"callerRevisionSelectable": False,
|
||||
"callerCommandSelectable": False,
|
||||
"callerMountSelectable": False,
|
||||
"helperTargetSelectable": False,
|
||||
}
|
||||
if diagnostics is not None:
|
||||
after_state["diagnostics"] = diagnostics
|
||||
redacted_metadata["diagnostics"] = diagnostics
|
||||
|
||||
audit = log_event(
|
||||
session,
|
||||
source="action",
|
||||
@@ -1613,23 +1648,10 @@ def _execute_controller_artifact_promote(
|
||||
reason=reason,
|
||||
result="success" if success else "failed",
|
||||
execution_plan={"steps": steps, "jobId": completed.id},
|
||||
after_state={
|
||||
"status": status,
|
||||
"revision": result.get("revision"),
|
||||
"controllerVersion": result.get("controllerVersion"),
|
||||
"artifactSha256": result.get("artifactSha256"),
|
||||
},
|
||||
redacted_metadata={
|
||||
"executor": action,
|
||||
"integration": "mcp" if source == "chatgpt-mcp" else "operator",
|
||||
"originalPolicyDecision": original_policy_decision,
|
||||
"callerRevisionSelectable": False,
|
||||
"callerCommandSelectable": False,
|
||||
"callerMountSelectable": False,
|
||||
"helperTargetSelectable": False,
|
||||
},
|
||||
after_state=after_state,
|
||||
redacted_metadata=redacted_metadata,
|
||||
)
|
||||
return {
|
||||
operation_result = {
|
||||
"success": success,
|
||||
"status": "success" if success else status,
|
||||
"result": "success" if success else "failed",
|
||||
@@ -1642,6 +1664,9 @@ def _execute_controller_artifact_promote(
|
||||
"controllerVersion": result.get("controllerVersion"),
|
||||
"artifactSha256": result.get("artifactSha256"),
|
||||
}
|
||||
if diagnostics is not None:
|
||||
operation_result["diagnostics"] = diagnostics
|
||||
return operation_result
|
||||
|
||||
|
||||
def _execute_runner_fleet_upgrade_v2(
|
||||
|
||||
@@ -178,6 +178,7 @@ ACTIONS = [
|
||||
'authentik.unknown_mutation',
|
||||
'forge.oidc.provision',
|
||||
'forge.secrets.prepare',
|
||||
'forge.route.stage',
|
||||
'tautulli.terminate_session',
|
||||
'tautulli.delete_newsletter',
|
||||
'tautulli.delete_notification_log',
|
||||
@@ -390,6 +391,7 @@ ACTION_RISK = {
|
||||
'authentik.unknown_mutation': 'critical',
|
||||
'forge.oidc.provision': 'critical',
|
||||
'forge.secrets.prepare': 'critical',
|
||||
'forge.route.stage': 'critical',
|
||||
'tautulli.terminate_session': 'high',
|
||||
'tautulli.delete_newsletter': 'critical',
|
||||
'tautulli.delete_notification_log': 'critical',
|
||||
@@ -539,6 +541,8 @@ def action_default_target(action: str) -> str:
|
||||
return 'itworx-forge'
|
||||
if action == 'forge.secrets.prepare':
|
||||
return 'itworx-forge'
|
||||
if action == 'forge.route.stage':
|
||||
return 'itworx-forge'
|
||||
if action == 'controller.artifact.promote':
|
||||
return 'tower/deployment-controller'
|
||||
if action == 'controller.managed-storage.initialize':
|
||||
|
||||
@@ -43,24 +43,68 @@ def helper_container_spec(
|
||||
raise ValueError("controller_promoter_bootstrap_binding_invalid")
|
||||
|
||||
command = f"""set -Eeuo pipefail
|
||||
phase=shell_setup
|
||||
work=''
|
||||
diagnostic_emitted=0
|
||||
emit_failure() {{
|
||||
local exit_code="$1"
|
||||
if (( diagnostic_emitted == 0 )); then
|
||||
printf 'APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=%s exitCode=%s\\n' "$phase" "$exit_code" >&2
|
||||
diagnostic_emitted=1
|
||||
fi
|
||||
}}
|
||||
on_error() {{
|
||||
local exit_code=$?
|
||||
trap - ERR
|
||||
emit_failure "$exit_code"
|
||||
exit "$exit_code"
|
||||
}}
|
||||
cleanup() {{
|
||||
local exit_code=$?
|
||||
local cleanup_exit_code=0
|
||||
trap - EXIT
|
||||
phase=cleanup
|
||||
if [[ -n "$work" ]]; then
|
||||
if rm -rf -- "$work"; then
|
||||
:
|
||||
else
|
||||
cleanup_exit_code=$?
|
||||
emit_failure "$cleanup_exit_code"
|
||||
if (( exit_code == 0 )); then exit_code=$cleanup_exit_code; fi
|
||||
fi
|
||||
fi
|
||||
exit "$exit_code"
|
||||
}}
|
||||
trap on_error ERR
|
||||
umask 077
|
||||
revision='{revision}'
|
||||
version='{CONTROLLER_VERSION}'
|
||||
image_id='{image_id}'
|
||||
repo='/gitea-repositories/jens/unraid-appops-gateway.git'
|
||||
[[ \"$(stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]]
|
||||
work=\"$(mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\"
|
||||
phase=tmp_root
|
||||
[[ \"$(trap - ERR; stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]]
|
||||
phase=workspace_create
|
||||
work=\"$(trap - ERR; mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\"
|
||||
phase=workspace_validate
|
||||
[[ -d \"$work\" && ! -L \"$work\" && \"$(stat -c '%u:%a' \"$work\")\" == '0:700' ]]
|
||||
cleanup() {{ rm -rf -- \"$work\"; }}
|
||||
trap cleanup EXIT
|
||||
phase=tool_git
|
||||
command -v git >/dev/null
|
||||
phase=tool_docker
|
||||
command -v docker >/dev/null
|
||||
phase=tool_compose
|
||||
docker compose version >/dev/null
|
||||
phase=tool_python
|
||||
command -v python3 >/dev/null
|
||||
phase=tool_tar
|
||||
command -v tar >/dev/null
|
||||
phase=tool_curl
|
||||
command -v curl >/dev/null
|
||||
phase=source_commit
|
||||
git --git-dir=\"$repo\" cat-file -e \"$revision^{{commit}}\"
|
||||
tree=\"$(git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\"
|
||||
phase=source_tree
|
||||
tree=\"$(trap - ERR; git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\"
|
||||
phase=source_archive
|
||||
git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\
|
||||
ops/gitea-deploy-broker \\
|
||||
scripts/prepare_controller_artifact.py \\
|
||||
@@ -69,10 +113,15 @@ git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\
|
||||
scripts/vendor \\
|
||||
scripts/run_repopulse_adoption.py \
|
||||
scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"
|
||||
phase=archive_permissions
|
||||
chmod 0600 \"$work/controller.tar\"
|
||||
archive_sha=\"$(sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\"
|
||||
phase=archive_digest
|
||||
archive_sha=\"$(trap - ERR; sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\"
|
||||
phase=upgrade_script
|
||||
git --git-dir=\"$repo\" show \"$revision:scripts/upgrade_deployment_controller.sh\" > \"$work/upgrade.sh\"
|
||||
phase=upgrade_permissions
|
||||
chmod 0700 \"$work/upgrade.sh\"
|
||||
phase=upgrade
|
||||
bash \"$work/upgrade.sh\" \\
|
||||
--archive \"$work/controller.tar\" \\
|
||||
--archive-sha256 \"$archive_sha\" \\
|
||||
|
||||
@@ -10,16 +10,17 @@ import httpx
|
||||
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from .controller_artifact_helper_spec import (
|
||||
_IMAGE_ID,
|
||||
_REVISION,
|
||||
CONTROLLER_VERSION,
|
||||
HELPER_LABEL,
|
||||
HELPER_NAME,
|
||||
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
|
||||
_IMAGE_ID,
|
||||
_REVISION,
|
||||
helper_container_spec,
|
||||
)
|
||||
from .controller_artifact_helper_spec import (
|
||||
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
|
||||
)
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
|
||||
GITEA_OWNER = "Jens"
|
||||
@@ -28,6 +29,132 @@ REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)"
|
||||
CONTROLLER_CONTAINER = "gitea-deploy-control"
|
||||
_HELPER_TIMEOUT_SECONDS = 240
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$")
|
||||
_MAX_HELPER_LOG_CHARS = 4000
|
||||
_MAX_HELPER_LOG_LINES = 200
|
||||
_MAX_HELPER_LOG_WIRE_BYTES = _MAX_HELPER_LOG_CHARS + (8 * _MAX_HELPER_LOG_LINES)
|
||||
_HELPER_DIAGNOSTIC_PREFIX = "APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC"
|
||||
_HELPER_DIAGNOSTIC_CODES = frozenset({"helper_failed"})
|
||||
_HELPER_DIAGNOSTIC_PHASES = frozenset(
|
||||
{
|
||||
"shell_setup",
|
||||
"tmp_root",
|
||||
"workspace_create",
|
||||
"workspace_validate",
|
||||
"tool_git",
|
||||
"tool_docker",
|
||||
"tool_compose",
|
||||
"tool_python",
|
||||
"tool_tar",
|
||||
"tool_curl",
|
||||
"source_commit",
|
||||
"source_tree",
|
||||
"source_archive",
|
||||
"archive_permissions",
|
||||
"archive_digest",
|
||||
"upgrade_script",
|
||||
"upgrade_permissions",
|
||||
"upgrade",
|
||||
"cleanup",
|
||||
}
|
||||
)
|
||||
_HELPER_DIAGNOSTIC_LINE = re.compile(
|
||||
rf"^{_HELPER_DIAGNOSTIC_PREFIX} code=([a-z_]+) phase=([a-z_]+) "
|
||||
r"exitCode=(0|[1-9][0-9]{0,2})$"
|
||||
)
|
||||
|
||||
|
||||
def helper_failure_diagnostics(exit_code: Any) -> dict[str, Any]:
|
||||
safe_exit_code = exit_code if type(exit_code) is int and 0 <= exit_code <= 255 else None
|
||||
return {"code": "helper_failed", "phase": "unknown", "exitCode": safe_exit_code}
|
||||
|
||||
|
||||
def safe_helper_diagnostics(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict) or set(value) != {"code", "phase", "exitCode"}:
|
||||
return None
|
||||
code = value.get("code")
|
||||
phase = value.get("phase")
|
||||
exit_code = value.get("exitCode")
|
||||
if not isinstance(code, str) or code not in _HELPER_DIAGNOSTIC_CODES:
|
||||
return None
|
||||
if not isinstance(phase, str) or (
|
||||
phase != "unknown" and phase not in _HELPER_DIAGNOSTIC_PHASES
|
||||
):
|
||||
return None
|
||||
if exit_code is not None and (
|
||||
type(exit_code) is not int or not 0 <= exit_code <= 255
|
||||
):
|
||||
return None
|
||||
return {"code": code, "phase": phase, "exitCode": exit_code}
|
||||
|
||||
|
||||
def parse_helper_diagnostics(log_text: Any, exit_code: Any) -> dict[str, Any]:
|
||||
fallback = helper_failure_diagnostics(exit_code)
|
||||
if (
|
||||
fallback["exitCode"] is None
|
||||
or not isinstance(log_text, str)
|
||||
or len(log_text) > _MAX_HELPER_LOG_CHARS
|
||||
):
|
||||
return fallback
|
||||
|
||||
candidates = [
|
||||
line
|
||||
for line in log_text.splitlines()
|
||||
if line.startswith(_HELPER_DIAGNOSTIC_PREFIX)
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
return fallback
|
||||
match = _HELPER_DIAGNOSTIC_LINE.fullmatch(candidates[0])
|
||||
if match is None:
|
||||
return fallback
|
||||
code, phase, marker_exit_code = match.groups()
|
||||
parsed_exit_code = int(marker_exit_code)
|
||||
if (
|
||||
code not in _HELPER_DIAGNOSTIC_CODES
|
||||
or phase not in _HELPER_DIAGNOSTIC_PHASES
|
||||
or parsed_exit_code != fallback["exitCode"]
|
||||
):
|
||||
return fallback
|
||||
return {"code": code, "phase": phase, "exitCode": parsed_exit_code}
|
||||
|
||||
|
||||
def _decode_docker_log_stream(content: Any) -> str | None:
|
||||
if (
|
||||
not isinstance(content, bytes)
|
||||
or not content
|
||||
or len(content) > _MAX_HELPER_LOG_WIRE_BYTES
|
||||
):
|
||||
return None
|
||||
|
||||
offset = 0
|
||||
frame_count = 0
|
||||
payload_size = 0
|
||||
payloads: list[bytes] = []
|
||||
while offset < len(content):
|
||||
if frame_count >= _MAX_HELPER_LOG_LINES or len(content) - offset < 8:
|
||||
return None
|
||||
|
||||
header = content[offset : offset + 8]
|
||||
stream_type = header[0]
|
||||
if stream_type not in {1, 2} or header[1:4] != b"\x00\x00\x00":
|
||||
return None
|
||||
frame_size = int.from_bytes(header[4:8], "big")
|
||||
if frame_size == 0 or frame_size > _MAX_HELPER_LOG_CHARS - payload_size:
|
||||
return None
|
||||
|
||||
payload_start = offset + 8
|
||||
payload_end = payload_start + frame_size
|
||||
if payload_end > len(content):
|
||||
return None
|
||||
|
||||
payloads.append(content[payload_start:payload_end])
|
||||
payload_size += frame_size
|
||||
frame_count += 1
|
||||
offset = payload_end
|
||||
|
||||
try:
|
||||
return b"".join(payloads).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
class ControllerArtifactPromotionError(RuntimeError):
|
||||
@@ -257,10 +384,29 @@ class ControllerArtifactPromoter:
|
||||
}
|
||||
|
||||
exit_code = state.get("ExitCode")
|
||||
logs = self.portainer.get_container_logs(helper_id, tail=200)
|
||||
log_lines = logs.get("logs") if isinstance(logs, dict) and isinstance(logs.get("logs"), list) else []
|
||||
tail = "\n".join(str(line) for line in log_lines[-40:])[-4000:]
|
||||
if exit_code != 0:
|
||||
if type(exit_code) is not int or exit_code != 0:
|
||||
try:
|
||||
logs_response = self.portainer._request(
|
||||
"GET",
|
||||
f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/logs",
|
||||
params={
|
||||
"stdout": 1,
|
||||
"stderr": 1,
|
||||
"tail": _MAX_HELPER_LOG_LINES,
|
||||
},
|
||||
)
|
||||
log_text = (
|
||||
_decode_docker_log_stream(logs_response.content)
|
||||
if isinstance(logs_response, httpx.Response)
|
||||
and logs_response.status_code == 200
|
||||
else None
|
||||
)
|
||||
except (httpx.HTTPError, RuntimeError, ValueError):
|
||||
log_text = None
|
||||
diagnostics = parse_helper_diagnostics(
|
||||
log_text,
|
||||
exit_code,
|
||||
)
|
||||
try:
|
||||
self._remove_helper(helper_id)
|
||||
except RuntimeError:
|
||||
@@ -269,15 +415,14 @@ class ControllerArtifactPromoter:
|
||||
"status": "failed",
|
||||
"revision": revision,
|
||||
"reason": "controller_promoter_helper_failed",
|
||||
"exitCode": exit_code,
|
||||
"logTail": redact_message(tail),
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
try:
|
||||
self._remove_helper(helper_id)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return {"status": "promoted", "revision": revision, "logTail": redact_message(tail)}
|
||||
return {"status": "promoted", "revision": revision}
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
revision = self._validated_main_revision()
|
||||
|
||||
@@ -214,9 +214,7 @@ class DeploymentBrokerClient:
|
||||
}
|
||||
for field in error_response_fields:
|
||||
value = decoded.get(field)
|
||||
if isinstance(value, str) and len(value) <= 256:
|
||||
rejected[field] = value
|
||||
elif field == "noMutation" and isinstance(value, bool):
|
||||
if isinstance(value, str) and len(value) <= 256 or field == "noMutation" and isinstance(value, bool):
|
||||
rejected[field] = value
|
||||
return rejected
|
||||
return {"status": str(decoded.get("status") or "ok"), **decoded}
|
||||
@@ -279,6 +277,33 @@ class DeploymentBrokerClient:
|
||||
"reason": reason, "expectedCurrentRevision": expected_current_revision},
|
||||
)
|
||||
|
||||
def hub_component_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Only closed, signed component-promotion endpoints; never caller commands."""
|
||||
if stage not in {"baseline", "preflight", "apply", "status"}:
|
||||
raise ValueError("component promotion stage is invalid")
|
||||
return self._request(
|
||||
"POST", f"/v1/appops/hub-components/{stage}",
|
||||
secret=settings.mcp_token or "", payload=payload,
|
||||
error_response_fields=frozenset({"planId", "planDigest", "noMutation"}),
|
||||
)
|
||||
|
||||
def hub_auto_apply(self, payload: dict[str, Any], *, preflight: bool = False) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST", "/v1/appops/registered-targets/auto-apply" + ("/preflight" if preflight else ""),
|
||||
secret=settings.mcp_token or "", payload=payload,
|
||||
error_response_fields=frozenset({"noMutation"}),
|
||||
)
|
||||
|
||||
def registered_legacy_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Signed fixed-stage metadata adoption/recovery for existing registered targets."""
|
||||
if stage not in {"baseline", "preflight", "adopt", "status", "rollback", "delivery"}:
|
||||
raise ValueError("registered legacy stage is invalid")
|
||||
return self._request(
|
||||
"POST", f"/v1/appops/registered-legacy/{stage}",
|
||||
secret=settings.mcp_token or "", payload=payload,
|
||||
error_response_fields=frozenset({"noMutation"}),
|
||||
)
|
||||
|
||||
def appops_self_upgrade_apply(
|
||||
self, revision: str, reason: str, expected_current_revision: str | None,
|
||||
preflight_sha256: str, idempotency_key: str,
|
||||
|
||||
@@ -26,6 +26,7 @@ import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -36,6 +37,8 @@ host_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
|
||||
repository, revision, branch, raw_project = sys.argv[1:5]
|
||||
project = json.loads(raw_project)
|
||||
requested_project = project
|
||||
strengthening_deadline = time.monotonic() + 20
|
||||
|
||||
def _validation_compatible(repository, validation):
|
||||
expected_validation = {
|
||||
@@ -58,6 +61,52 @@ def _validation_compatible(repository, validation):
|
||||
"contexts": ["CI / gates (push)", "Managed validation / full (push)"],
|
||||
}
|
||||
|
||||
def _source_verified_strengthening():
|
||||
# Only an exact protected-main descriptor can strengthen an existing target.
|
||||
# The controller reader uses fixed credential files and private stdin.
|
||||
if branch != "main" or time.monotonic() >= strengthening_deadline:
|
||||
return False
|
||||
try:
|
||||
fresh = git_run("rev-parse", "refs/heads/main")
|
||||
if fresh.returncode or fresh.stdout.strip() != revision:
|
||||
return False
|
||||
result = git_run("show", f"{revision}:.itworx/deployment-target.json")
|
||||
if result.returncode or len(result.stdout.encode("utf-8")) > 16384:
|
||||
return False
|
||||
def unique(pairs):
|
||||
value = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate descriptor field")
|
||||
value[key] = item
|
||||
return value
|
||||
descriptor = json.loads(result.stdout, object_pairs_hook=unique)
|
||||
allowed = {"apiVersion", "applicationId", "composeFile", "composeProject",
|
||||
"healthTimeout", "primaryService", "verifyOciRevision"}
|
||||
if (not isinstance(descriptor, dict) or set(descriptor) - allowed
|
||||
or descriptor.get("apiVersion") != "itworx.deployment-target/v1"
|
||||
or descriptor.get("verifyOciRevision") is not True
|
||||
or descriptor.get("composeFile") != compose_file
|
||||
or descriptor.get("composeProject", descriptor.get("applicationId")) != requested_project["compose_project"]
|
||||
or type(descriptor.get("healthTimeout", 180)) is not int
|
||||
or descriptor.get("healthTimeout", 180) != requested_project["health_timeout"]
|
||||
or not isinstance(descriptor.get("applicationId"), str)
|
||||
or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", descriptor["applicationId"])
|
||||
or not isinstance(descriptor.get("primaryService", descriptor["applicationId"]), str)
|
||||
or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", descriptor.get("primaryService", descriptor["applicationId"]))):
|
||||
return False
|
||||
sys.path.insert(0, "/opt/gitea-deploy")
|
||||
from status_server import _registered_legacy_api_reader
|
||||
protected = _registered_legacy_api_reader(repository)(
|
||||
f"/repos/{repository}/branches/main", query={},
|
||||
)
|
||||
return (time.monotonic() < strengthening_deadline
|
||||
and isinstance(protected, dict) and protected.get("protected") is True
|
||||
and isinstance(protected.get("commit"), dict)
|
||||
and protected["commit"].get("id") == revision)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
for directory in (path.parent, host_path.parent, host_path.parent.parent):
|
||||
info = directory.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
@@ -89,8 +138,16 @@ if not bare_repo.is_dir():
|
||||
raise SystemExit(24)
|
||||
|
||||
def git_run(*args):
|
||||
# Trust only this validated controller-owned mount, preserving Git's normal
|
||||
# checks elsewhere. Always read original objects rather than replacement refs.
|
||||
for directory in (bare_repo.parent.parent, bare_repo.parent, bare_repo):
|
||||
info = directory.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid not in {0, 1000}
|
||||
or info.st_mode & 0o022):
|
||||
raise ValueError("unsafe mounted repository")
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={bare_repo}", *args],
|
||||
["git", "--no-replace-objects", "-c", f"safe.directory={bare_repo}",
|
||||
f"--git-dir={bare_repo}", *args],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -168,12 +225,34 @@ with lock_handle as lock:
|
||||
"gitea_repository_id",
|
||||
"gitea_webhook_secret_file",
|
||||
"validation",
|
||||
"legacy_containers",
|
||||
}
|
||||
extra_keys = set(current) - set(project)
|
||||
if not extra_keys.issubset(allowed_extra_keys):
|
||||
raise SystemExit(23)
|
||||
canonical_current = {key: current.get(key) for key in project}
|
||||
if canonical_current != project:
|
||||
immutable_keys = set(project) - {"source_dir", "exact_source", "verify_oci_revision", "health_timeout"}
|
||||
if any(current.get(key) != project[key] for key in immutable_keys):
|
||||
raise SystemExit(23)
|
||||
# Preserve the registered source mode and fixed historical appdata path.
|
||||
# Registration must never silently migrate an existing runtime's source.
|
||||
source = current.get("source_dir")
|
||||
known_sources = {project["source_dir"], f"/srv/example/appdata/{project['slug']}/source"}
|
||||
exact_source = current.get("exact_source", False)
|
||||
verification = current.get("verify_oci_revision", False)
|
||||
health_timeout = current.get("health_timeout", 180)
|
||||
if (not isinstance(source, str) or source not in known_sources or type(exact_source) is not bool
|
||||
or type(verification) is not bool or type(project["verify_oci_revision"]) is not bool
|
||||
or type(health_timeout) is not int or health_timeout != project["health_timeout"]
|
||||
or verification and project["verify_oci_revision"] is not True):
|
||||
raise SystemExit(23)
|
||||
legacy_containers = current.get("legacy_containers")
|
||||
if legacy_containers is not None and (
|
||||
not isinstance(legacy_containers, list) or len(legacy_containers) > 32
|
||||
or any(not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name)
|
||||
for name in legacy_containers)
|
||||
or len(set(legacy_containers)) != len(legacy_containers)
|
||||
or set(legacy_containers).intersection(project["containers"])
|
||||
):
|
||||
raise SystemExit(23)
|
||||
env_source = current.get("env_source")
|
||||
if env_source is not None:
|
||||
@@ -222,6 +301,15 @@ with lock_handle as lock:
|
||||
if not _validation_compatible(repository, validation):
|
||||
raise SystemExit(23)
|
||||
|
||||
strengthened = not verification and project["verify_oci_revision"] is True
|
||||
if strengthened and not _source_verified_strengthening():
|
||||
raise SystemExit(39)
|
||||
reconciled = dict(current)
|
||||
if strengthened:
|
||||
reconciled["verify_oci_revision"] = True
|
||||
changed = reconciled != current
|
||||
project = reconciled
|
||||
|
||||
if changed:
|
||||
try:
|
||||
backup_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups")
|
||||
@@ -266,11 +354,17 @@ with lock_handle as lock:
|
||||
raise SystemExit(33)
|
||||
|
||||
try:
|
||||
if current is not None and strengthened and not _source_verified_strengthening():
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise SystemExit(39)
|
||||
latest = mutation_path.lstat()
|
||||
latest_identity = (latest.st_dev, latest.st_ino, latest.st_mode, latest.st_uid,
|
||||
latest.st_gid, latest.st_mtime_ns, latest.st_ctime_ns, latest.st_size)
|
||||
if latest_identity != registry_identity or mutation_path.read_bytes() != original or not os.path.samefile(path, mutation_path):
|
||||
raise SystemExit(34)
|
||||
if current is not None and strengthened and time.monotonic() >= strengthening_deadline:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise SystemExit(39)
|
||||
os.replace(temporary, mutation_path)
|
||||
except Exception:
|
||||
try:
|
||||
@@ -429,6 +523,8 @@ class DeploymentTargetRegistry:
|
||||
verify_oci_revision: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
if type(verify_oci_revision) is not bool:
|
||||
raise ValueError("OCI revision verification must be a boolean")
|
||||
(
|
||||
repository,
|
||||
revision,
|
||||
@@ -561,6 +657,7 @@ class DeploymentTargetRegistry:
|
||||
36: "deployment projects registry host alias is unavailable or does not match the controller registry",
|
||||
37: "controller project branch does not match the requested default branch",
|
||||
38: "controller project Compose contract is invalid",
|
||||
39: "existing target verification strengthening requires its exact protected-main source descriptor",
|
||||
}
|
||||
if exit_code in preflight_reasons:
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,7 @@ import secrets
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
@@ -46,6 +47,20 @@ _CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "ap
|
||||
_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"}
|
||||
_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
|
||||
_APPDATA_ROOT_STATUSES = {"ready", "needs_initialization", "unavailable"}
|
||||
_CAPABILITY_REASON = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
|
||||
_RUNTIME_SECURITY_FEATURES = ("exactTargetPolicy", "hmacV2", "replayProtection")
|
||||
|
||||
|
||||
def _safe_capability_reason(value: object) -> str:
|
||||
reason = str(value or "")
|
||||
return reason if _CAPABILITY_REASON.fullmatch(reason) else ""
|
||||
|
||||
|
||||
def _features_ready(features: object, names: tuple[str, ...]) -> bool:
|
||||
return bool(
|
||||
isinstance(features, dict)
|
||||
and all(features.get(name) is True for name in names)
|
||||
)
|
||||
|
||||
|
||||
def _trusted_appdata_roots_readiness(
|
||||
@@ -57,7 +72,7 @@ def _trusted_appdata_roots_readiness(
|
||||
if isinstance(reported, list):
|
||||
if len(reported) != len(roots):
|
||||
return [{"root": root, "status": unavailable[root]} for root in roots]
|
||||
statuses: dict[str, str] = {}
|
||||
statuses: dict[str, dict[str, str]] = {}
|
||||
for item in reported:
|
||||
if not isinstance(item, dict):
|
||||
return [{"root": root, "status": unavailable[root]} for root in roots]
|
||||
@@ -71,10 +86,14 @@ def _trusted_appdata_roots_readiness(
|
||||
or status not in _APPDATA_ROOT_STATUSES
|
||||
):
|
||||
return [{"root": expected, "status": unavailable[expected]} for expected in roots]
|
||||
statuses[root] = status
|
||||
value = {"root": root, "status": status}
|
||||
reason = _safe_capability_reason(item.get("reason"))
|
||||
if reason:
|
||||
value["reason"] = reason
|
||||
statuses[root] = value
|
||||
if set(statuses) != set(roots):
|
||||
return [{"root": root, "status": unavailable[root]} for root in roots]
|
||||
return [{"root": root, "status": statuses[root]} for root in roots]
|
||||
return [statuses[root] for root in roots]
|
||||
|
||||
# A legacy V1 controller reports only its configured root. It cannot attest
|
||||
# independent managed-volume readiness, so only its exact legacy root can
|
||||
@@ -224,11 +243,40 @@ class EnrollmentPlane:
|
||||
controller_root_matches = bool(selected_root and selected_root.get("status") == "ready")
|
||||
else:
|
||||
controller_root_matches = False
|
||||
broker_ready = bool(
|
||||
controller_core_ready = bool(
|
||||
bootstrap
|
||||
and self.broker.is_configured()
|
||||
and controller_capability.get("status") == "ready"
|
||||
)
|
||||
features = controller_capability.get("features")
|
||||
runtime_security_ready = _features_ready(features, _RUNTIME_SECURITY_FEATURES)
|
||||
apply_ready = bool(
|
||||
controller_core_ready
|
||||
and runtime_security_ready
|
||||
and isinstance(features, dict)
|
||||
and features.get("deploymentApply") is True
|
||||
)
|
||||
verify_ready = bool(
|
||||
controller_core_ready
|
||||
and runtime_security_ready
|
||||
and isinstance(features, dict)
|
||||
and features.get("deploymentVerify") is True
|
||||
)
|
||||
rollback_ready = bool(
|
||||
controller_core_ready
|
||||
and runtime_security_ready
|
||||
and isinstance(features, dict)
|
||||
and features.get("rollback") is True
|
||||
)
|
||||
runtime_deployment_ready = apply_ready and verify_ready
|
||||
enrollment_features_ready = _features_ready(
|
||||
features,
|
||||
("enrollment", "targetRegistration", "runtimeSecretSealing"),
|
||||
)
|
||||
target_enrollment_ready = bool(
|
||||
controller_core_ready
|
||||
and controller_root_matches
|
||||
and enrollment_features_ready
|
||||
)
|
||||
controller_managed_storage = controller_capability.get("managedStorage")
|
||||
if not isinstance(controller_managed_storage, dict):
|
||||
@@ -248,6 +296,41 @@ class EnrollmentPlane:
|
||||
else:
|
||||
managed_storage_status = "unavailable"
|
||||
managed_storage_blocker = "managed_appdata_volume_unavailable"
|
||||
controller_roots_by_path = {
|
||||
str(item.get("root") or ""): item
|
||||
for item in available_roots
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
selected_root_readiness = (
|
||||
controller_roots_by_path.get(trust.trusted_appdata_root)
|
||||
if trust is not None
|
||||
else None
|
||||
)
|
||||
controller_enrollment = controller_capability.get("enrollmentReadiness")
|
||||
if not isinstance(controller_enrollment, dict):
|
||||
controller_enrollment = {}
|
||||
root_reason = _safe_capability_reason(
|
||||
selected_root_readiness.get("reason")
|
||||
if isinstance(selected_root_readiness, dict)
|
||||
else None
|
||||
)
|
||||
if not root_reason and trust is not None and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT:
|
||||
root_reason = _safe_capability_reason(controller_managed_storage.get("reason"))
|
||||
enrollment_reason = (
|
||||
root_reason
|
||||
or _safe_capability_reason(controller_enrollment.get("reason"))
|
||||
or (
|
||||
"enrollment_trust_not_configured"
|
||||
if trust is None
|
||||
else "trusted_appdata_root_unavailable"
|
||||
if not controller_root_matches
|
||||
else "controller_enrollment_feature_unavailable"
|
||||
if not enrollment_features_ready
|
||||
else "deployment_executor_unavailable"
|
||||
)
|
||||
)
|
||||
if target_enrollment_ready:
|
||||
enrollment_reason = ""
|
||||
npm_ready = self.npm.is_configured()
|
||||
oidc_configured = bool(
|
||||
trust
|
||||
@@ -262,18 +345,44 @@ class EnrollmentPlane:
|
||||
def item(ready: bool, code: str) -> dict[str, str]:
|
||||
return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code}
|
||||
|
||||
if not controller_core_ready:
|
||||
enrollment_blocker = "deployment_executor_unavailable"
|
||||
elif not controller_root_matches:
|
||||
enrollment_blocker = "trusted_appdata_root_unavailable"
|
||||
else:
|
||||
enrollment_blocker = "controller_enrollment_feature_unavailable"
|
||||
registry_report = controller_capability.get("registry")
|
||||
registry_projection: dict[str, Any] = {"status": "unavailable"}
|
||||
if isinstance(registry_report, dict):
|
||||
registry_status = registry_report.get("status")
|
||||
if registry_status in {"ready", "unavailable"}:
|
||||
registry_projection["status"] = registry_status
|
||||
count = registry_report.get("registeredProjectCount")
|
||||
if (
|
||||
registry_status == "ready"
|
||||
and type(count) is int
|
||||
and 0 <= count <= 500
|
||||
):
|
||||
registry_projection["registeredProjectCount"] = count
|
||||
controller_reason = (
|
||||
""
|
||||
if controller_core_ready
|
||||
else _safe_capability_reason(controller_capability.get("reason"))
|
||||
or "deployment_executor_unavailable"
|
||||
)
|
||||
|
||||
return {
|
||||
"apiVersion": "itworx.enrollment-capabilities/v1",
|
||||
"trust": item(configured, "enrollment_trust_not_configured"),
|
||||
"targetEnrollment": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"targetEnrollment": item(target_enrollment_ready, enrollment_blocker),
|
||||
"secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"),
|
||||
"runtimeDeployment": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"runtimeDeployment": item(runtime_deployment_ready, "deployment_executor_unavailable"),
|
||||
"reverseProxyProvisioning": item(npm_ready, "npm_credentials_missing"),
|
||||
"tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"),
|
||||
"oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"),
|
||||
"policyProvisioning": item(configured, "policy_template_unavailable"),
|
||||
"rollback": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"verification": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"rollback": item(rollback_ready, "deployment_executor_unavailable"),
|
||||
"verification": item(verify_ready, "deployment_executor_unavailable"),
|
||||
"availableTrustedAppdataRoots": available_roots,
|
||||
"managedStorage": {
|
||||
"status": managed_storage_status,
|
||||
@@ -283,19 +392,23 @@ class EnrollmentPlane:
|
||||
"backupAcknowledged": bool(
|
||||
trust and trust.managed_appdata_backup_acknowledged
|
||||
),
|
||||
},
|
||||
"controller": {
|
||||
"status": "ready" if broker_ready else "unavailable",
|
||||
"reason": (
|
||||
""
|
||||
if broker_ready
|
||||
else str(controller_capability.get("reason") or (
|
||||
"trusted_appdata_root_mismatch"
|
||||
if controller_capability.get("status") == "ready" and not controller_root_matches
|
||||
else "deployment_executor_unavailable"
|
||||
))
|
||||
**(
|
||||
{"reason": _safe_capability_reason(controller_managed_storage.get("reason"))}
|
||||
if trust is not None
|
||||
and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT
|
||||
and _safe_capability_reason(controller_managed_storage.get("reason"))
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"enrollmentReadiness": {
|
||||
"status": "ready" if target_enrollment_ready else "unavailable",
|
||||
"reason": enrollment_reason,
|
||||
},
|
||||
"registry": registry_projection,
|
||||
"controller": {
|
||||
"status": "ready" if controller_core_ready else "unavailable",
|
||||
"reason": controller_reason,
|
||||
},
|
||||
"profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")],
|
||||
}
|
||||
|
||||
@@ -433,6 +546,18 @@ class EnrollmentPlane:
|
||||
if by_repository and (not by_application or by_repository.id != by_application.id):
|
||||
checks.append(_check("target_already_exists", "blocked", "Repository is already bound to a different managed application."))
|
||||
|
||||
policy_ids = (
|
||||
f"enrollment-{plan.application_id}-deployment-apply",
|
||||
f"enrollment-{plan.application_id}-deployment-rollback",
|
||||
)
|
||||
policy_collisions = [policy_id for policy_id in policy_ids if session.get(Policy, policy_id) is not None]
|
||||
checks.append(_check(
|
||||
"exact_policy_id_collision",
|
||||
"blocked" if policy_collisions else "passed",
|
||||
"An enrollment-generated exact policy identifier is already owned and cannot be adopted."
|
||||
if policy_collisions else "Both enrollment-generated exact policy identifiers are available.",
|
||||
))
|
||||
|
||||
containers = self.portainer.list_containers()
|
||||
if containers.get("status") != "ok":
|
||||
checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection."))
|
||||
@@ -676,17 +801,25 @@ class EnrollmentPlane:
|
||||
"sourceRevision": plan.revision,
|
||||
"createdAt": utc_now().isoformat(),
|
||||
}
|
||||
identifiers: list[str] = []
|
||||
for action, suffix in (("deployment.apply", "deployment-apply"), ("deployment.rollback", "deployment-rollback")):
|
||||
policy_id = f"enrollment-{plan.application_id}-{suffix}"
|
||||
existing = session.get(Policy, policy_id)
|
||||
if existing is not None:
|
||||
# Enrollment never adopts or repurposes a pre-existing policy,
|
||||
# even where its visible action/target happen to match. A
|
||||
# replay is handled before this method; this branch is a
|
||||
# collision, not a safe recovery path.
|
||||
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
|
||||
existing = Policy(
|
||||
policy_specs = (
|
||||
(
|
||||
"deployment.apply",
|
||||
f"enrollment-{plan.application_id}-deployment-apply",
|
||||
),
|
||||
(
|
||||
"deployment.rollback",
|
||||
f"enrollment-{plan.application_id}-deployment-rollback",
|
||||
),
|
||||
)
|
||||
identifiers = [policy_id for _action, policy_id in policy_specs]
|
||||
# Check the complete pair before adding either row. The database flush
|
||||
# below is one transaction so a concurrent uniqueness collision cannot
|
||||
# leave the first insert committed by _fail().
|
||||
if any(session.get(Policy, policy_id) is not None for policy_id in identifiers):
|
||||
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
|
||||
|
||||
for action, policy_id in policy_specs:
|
||||
policy = Policy(
|
||||
id=policy_id,
|
||||
action=action,
|
||||
target=plan.application_id,
|
||||
@@ -695,10 +828,17 @@ class EnrollmentPlane:
|
||||
risk="high",
|
||||
reason="Enrollment-generated exact-target deployment policy.",
|
||||
)
|
||||
existing.provenance_json = provenance
|
||||
session.add(existing)
|
||||
identifiers.append(policy_id)
|
||||
session.commit()
|
||||
policy.provenance_json = provenance
|
||||
session.add(policy)
|
||||
try:
|
||||
# Force uniqueness errors inside this boundary; on failure discard
|
||||
# the uncommitted unit of work before _fail() persists its outcome.
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise EnrollmentError("exact policy identifier is already owned and cannot be adopted") from exc
|
||||
# Deliberately do not commit here: the caller commits these rows together
|
||||
# with the enrollment/job ready state after policy installation succeeds.
|
||||
return identifiers
|
||||
|
||||
def _compensate(
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.nginx_proxy_manager import (
|
||||
NginxProxyManagerConnector,
|
||||
_NpmForgeRoutePermissionDenied,
|
||||
)
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..db.models import DeploymentJob, Policy, Secret
|
||||
from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1
|
||||
from ..schemas.forge_route_stage import ForgeRouteStageResultV1
|
||||
from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessResultV1
|
||||
from .audit_logger import log_event
|
||||
from .forge_bootstrap_apply import (
|
||||
ForgeBootstrapApplyService,
|
||||
_classify_controller_result,
|
||||
_sha256,
|
||||
)
|
||||
from .forge_oidc_provisioning import _forge_oidc_process_lock
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret, secret_storage_status, set_secret
|
||||
|
||||
_ACTION = "forge.route.stage"
|
||||
_TARGET = "itworx-forge"
|
||||
_REPOSITORY = "jens/itworx-forge"
|
||||
_JOB_ID = re.compile(r"^forge-install-[0-9a-f]{64}$")
|
||||
_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
_OWNERSHIP_ID = re.compile(r"^froute-[0-9a-f]{32}$")
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
_DOMAIN_LABEL = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
_ROUTE_HOST = "forge.example.com"
|
||||
_ROUTE_NETWORK = "itworx-mcp-hub-npm"
|
||||
_ROUTE_UPSTREAM = "http://itworx-forge:8080"
|
||||
_INTENT_VERSION = "itworx.forge-route-stage-intent/v1"
|
||||
_INTENT_PHASES = {"pending", "outcome_unknown", "host_staged_disabled", "aborted_before_post"}
|
||||
_READINESS_SERVICES = ("api", "dashboard", "itworx-forge", "postgres", "worker")
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate journal key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def _reject_json_constant(_constant: str) -> Any:
|
||||
raise ValueError("invalid journal number")
|
||||
|
||||
|
||||
def _readiness_is_fresh(identity: dict[str, Any]) -> bool:
|
||||
observed_at = identity.get("observedAt")
|
||||
if type(observed_at) is not int:
|
||||
return False
|
||||
now = int(time.time())
|
||||
return now - 30 <= observed_at <= now + 5
|
||||
|
||||
|
||||
def _journal_secret_ref(job_id: str) -> str:
|
||||
# set_secret normalizes reference names to uppercase before persistence.
|
||||
return ("FORGE_ROUTE_STAGE_" + hashlib.sha256(job_id.encode("ascii")).hexdigest()).upper()
|
||||
|
||||
|
||||
def _secret_store_ready() -> bool:
|
||||
try:
|
||||
status = secret_storage_status()
|
||||
except Exception: # noqa: BLE001 - secret-store diagnostics never enter route results.
|
||||
return False
|
||||
return isinstance(status, dict) and status.get("productionReady") is True
|
||||
|
||||
|
||||
def _exact_route_policy_allowed(session: Session) -> str | None:
|
||||
rows = session.exec(select(Policy).where(Policy.action == _ACTION)).all()
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
policy = rows[0]
|
||||
if (
|
||||
policy.target != _TARGET
|
||||
or policy.manual != "allowed"
|
||||
or policy.voice != "blocked"
|
||||
or policy.risk != "critical"
|
||||
):
|
||||
return None
|
||||
decision = evaluate_policy(session, _ACTION, _TARGET, "manual")
|
||||
if (
|
||||
decision.get("decision") != "allowed"
|
||||
or decision.get("scope_decision") != "allowed"
|
||||
or decision.get("policy_id") != policy.id
|
||||
):
|
||||
return None
|
||||
return policy.id
|
||||
|
||||
|
||||
def _result(job_id: str, status: str, message: str) -> dict[str, Any]:
|
||||
staged = status == "host_staged_disabled"
|
||||
return ForgeRouteStageResultV1.model_validate(
|
||||
{
|
||||
"success": staged,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"target": _TARGET,
|
||||
"jobId": job_id,
|
||||
"hostname": _ROUTE_HOST,
|
||||
"upstream": _ROUTE_UPSTREAM,
|
||||
"routeState": "host_staged_disabled" if staged else "not_routed",
|
||||
"routed": False,
|
||||
}
|
||||
).model_dump(mode="json", by_alias=True)
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
*,
|
||||
actor_identity: str,
|
||||
decision: str,
|
||||
policy_id: str | None,
|
||||
result: dict[str, Any],
|
||||
idempotency_hash: str,
|
||||
) -> bool:
|
||||
try:
|
||||
log_event(
|
||||
session,
|
||||
source="manual",
|
||||
identity=actor_identity,
|
||||
action_id=_ACTION,
|
||||
target=_TARGET,
|
||||
target_type="forge_route_stage",
|
||||
risk="critical",
|
||||
policy_decision=decision,
|
||||
reason=result["message"],
|
||||
result=result["status"],
|
||||
after_state={
|
||||
"status": result["status"],
|
||||
"routeState": result["routeState"],
|
||||
"routed": False,
|
||||
},
|
||||
redacted_metadata={
|
||||
"policyId": policy_id,
|
||||
"idempotencyKeySha256": idempotency_hash,
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - audit/DB failures never expose held connector data.
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001 - keep DB details out of the response.
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _verified_job(session: Session, job_id: str) -> tuple[DeploymentJob, ForgeBootstrapApplyV1] | None:
|
||||
if _JOB_ID.fullmatch(job_id) is None:
|
||||
return None
|
||||
try:
|
||||
job = session.get(DeploymentJob, job_id)
|
||||
if (
|
||||
job is None
|
||||
or job.application_id != _TARGET
|
||||
or job.repository != _REPOSITORY
|
||||
or job.status != "deployed"
|
||||
or job.stage not in {"controller_deployed", "resume_controller_deployed"}
|
||||
or job.completed_at is None
|
||||
or not isinstance(job.broker_request_id, str)
|
||||
or _SHA256.fullmatch(job.broker_request_id) is None
|
||||
or not isinstance(job.plan_json, dict)
|
||||
or _sha256(job.plan_json) != job.plan_sha256
|
||||
):
|
||||
return None
|
||||
profile = ForgeBootstrapApplyV1.model_validate(job.plan_json)
|
||||
preflight_receipt = job.preflight_json
|
||||
if (
|
||||
job.revision != profile.revision
|
||||
or not isinstance(preflight_receipt, dict)
|
||||
or set(preflight_receipt) != {"apiVersion", "jobId", "sha256"}
|
||||
or preflight_receipt
|
||||
!= {
|
||||
"apiVersion": "itworx.forge-bootstrap-preflight/v1",
|
||||
"jobId": profile.preflight_job_id,
|
||||
"sha256": profile.preflight_sha256,
|
||||
}
|
||||
):
|
||||
return None
|
||||
evidence = job.evidence_json
|
||||
if (
|
||||
not isinstance(evidence, dict)
|
||||
or set(evidence) != {"profile", "controller"}
|
||||
or evidence.get("profile") != "forge-first-install"
|
||||
):
|
||||
return None
|
||||
historical_status, _stage, _summary, _safe_evidence = _classify_controller_result(
|
||||
evidence.get("controller"),
|
||||
request_id=job.broker_request_id,
|
||||
profile=profile,
|
||||
)
|
||||
if historical_status != "deployed":
|
||||
return None
|
||||
return job, profile
|
||||
except Exception: # noqa: BLE001 - invalid stored job data is a fixed denial.
|
||||
return None
|
||||
|
||||
|
||||
def _readiness_identity(
|
||||
value: object,
|
||||
*,
|
||||
job: DeploymentJob,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
) -> dict[str, Any] | None:
|
||||
try:
|
||||
readiness = ForgeRuntimeReadinessResultV1.model_validate(value)
|
||||
except Exception: # noqa: BLE001 - the current readiness projection is untrusted input.
|
||||
return None
|
||||
now = int(time.time())
|
||||
artifact = readiness.controller_artifact
|
||||
if (
|
||||
readiness.status != "ready"
|
||||
or readiness.target != _TARGET
|
||||
or readiness.job_id != job.id
|
||||
or readiness.revision != profile.revision
|
||||
or readiness.workspace_image_digest != profile.workspace_image_digest
|
||||
or readiness.frontdoor_image_digest != profile.frontdoor_image_digest
|
||||
or type(readiness.observed_at) is not int
|
||||
or readiness.observed_at < now - 30
|
||||
or readiness.observed_at > now + 5
|
||||
or readiness.migration_completed is not True
|
||||
or readiness.health.status != "healthy"
|
||||
or tuple(item.name for item in readiness.health.services) != _READINESS_SERVICES
|
||||
or any(item.status != "healthy" for item in readiness.health.services)
|
||||
or artifact is None
|
||||
or artifact.source_repository != "jens/unraid-appops-gateway"
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", artifact.source_revision)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", artifact.source_tree)
|
||||
or not _SHA256.fullmatch(artifact.artifact_sha256)
|
||||
):
|
||||
return None
|
||||
identity = {
|
||||
"jobId": job.id,
|
||||
"observedAt": readiness.observed_at,
|
||||
"revision": profile.revision,
|
||||
"workspaceImageDigest": profile.workspace_image_digest,
|
||||
"frontdoorImageDigest": profile.frontdoor_image_digest,
|
||||
"migrationCompleted": True,
|
||||
"healthServices": list(_READINESS_SERVICES),
|
||||
"controllerArtifact": {
|
||||
"sourceRepository": artifact.source_repository,
|
||||
"sourceRevision": artifact.source_revision,
|
||||
"sourceTree": artifact.source_tree,
|
||||
"artifactSha256": artifact.artifact_sha256,
|
||||
},
|
||||
}
|
||||
return {"sha256": hashlib.sha256(_canonical_json(identity)).hexdigest(), **identity}
|
||||
|
||||
|
||||
def _network_ready(value: object) -> bool:
|
||||
return bool(
|
||||
isinstance(value, dict)
|
||||
and set(value) == {"status", "network", "npmAttached", "frontdoorAttached"}
|
||||
and value.get("status") == "ready"
|
||||
and value.get("network") == _ROUTE_NETWORK
|
||||
and value.get("npmAttached") is True
|
||||
and value.get("frontdoorAttached") is True
|
||||
)
|
||||
|
||||
|
||||
def _forge_route_npm_session(npm: NginxProxyManagerConnector) -> Any | None:
|
||||
"""Require the connector's fixed route-only HTTPS transport contract."""
|
||||
factory = getattr(npm, "forge_route_session", None)
|
||||
if not callable(factory):
|
||||
return None
|
||||
try:
|
||||
route_session = factory()
|
||||
except Exception: # noqa: BLE001 - connector configuration is private.
|
||||
return None
|
||||
endpoint_sha256 = getattr(route_session, "endpoint_sha256", None)
|
||||
if (
|
||||
getattr(route_session, "verified_tls", None) is not True
|
||||
or not isinstance(endpoint_sha256, str)
|
||||
or _SHA256.fullmatch(endpoint_sha256) is None
|
||||
):
|
||||
return None
|
||||
return route_session
|
||||
|
||||
|
||||
def _desired_state(ownership_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"domain_names": [_ROUTE_HOST],
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "itworx-forge",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": 0,
|
||||
"certificate_id": 0,
|
||||
"enabled": False,
|
||||
"ssl_forced": False,
|
||||
"http2_support": False,
|
||||
"hsts_enabled": False,
|
||||
"hsts_subdomains": False,
|
||||
"caching_enabled": False,
|
||||
"block_exploits": True,
|
||||
"allow_websocket_upgrade": False,
|
||||
"trust_forwarded_proto": False,
|
||||
"locations": [],
|
||||
"meta": {"appops_forge_route_id": ownership_id},
|
||||
"advanced_config": "",
|
||||
}
|
||||
|
||||
|
||||
def _build_record(
|
||||
*,
|
||||
job: DeploymentJob,
|
||||
profile: ForgeBootstrapApplyV1,
|
||||
preflight_receipt: dict[str, Any],
|
||||
idempotency_hash: str,
|
||||
readiness: dict[str, Any],
|
||||
ownership_id: str,
|
||||
npm_endpoint_sha256: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"apiVersion": _INTENT_VERSION,
|
||||
"jobId": job.id,
|
||||
"idempotencyKeySha256": idempotency_hash,
|
||||
"npmEndpointSha256": npm_endpoint_sha256,
|
||||
"ownershipId": ownership_id,
|
||||
"phase": "pending",
|
||||
"target": _TARGET,
|
||||
"repository": _REPOSITORY,
|
||||
"revision": profile.revision,
|
||||
"workspaceImageDigest": profile.workspace_image_digest,
|
||||
"frontdoorImageDigest": profile.frontdoor_image_digest,
|
||||
"preflightReceiptSha256": preflight_receipt["sha256"],
|
||||
"readinessIdentity": readiness,
|
||||
"desired": _desired_state(ownership_id),
|
||||
}
|
||||
|
||||
|
||||
def _record_valid(value: object, job_id: str) -> bool:
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"apiVersion",
|
||||
"jobId",
|
||||
"idempotencyKeySha256",
|
||||
"npmEndpointSha256",
|
||||
"ownershipId",
|
||||
"phase",
|
||||
"target",
|
||||
"repository",
|
||||
"revision",
|
||||
"workspaceImageDigest",
|
||||
"frontdoorImageDigest",
|
||||
"preflightReceiptSha256",
|
||||
"readinessIdentity",
|
||||
"desired",
|
||||
}:
|
||||
return False
|
||||
owner = value.get("ownershipId")
|
||||
identity = value.get("readinessIdentity")
|
||||
if (
|
||||
value.get("apiVersion") != _INTENT_VERSION
|
||||
or value.get("jobId") != job_id
|
||||
or not isinstance(value.get("idempotencyKeySha256"), str)
|
||||
or _SHA256.fullmatch(value["idempotencyKeySha256"]) is None
|
||||
or not isinstance(value.get("npmEndpointSha256"), str)
|
||||
or _SHA256.fullmatch(value["npmEndpointSha256"]) is None
|
||||
or not isinstance(owner, str)
|
||||
or _OWNERSHIP_ID.fullmatch(owner) is None
|
||||
or value.get("phase") not in _INTENT_PHASES
|
||||
or value.get("target") != _TARGET
|
||||
or value.get("repository") != _REPOSITORY
|
||||
or not isinstance(value.get("revision"), str)
|
||||
or re.fullmatch(r"[0-9a-f]{40}", value["revision"]) is None
|
||||
or not isinstance(value.get("workspaceImageDigest"), str)
|
||||
or _DIGEST.fullmatch(value["workspaceImageDigest"]) is None
|
||||
or not isinstance(value.get("frontdoorImageDigest"), str)
|
||||
or _DIGEST.fullmatch(value["frontdoorImageDigest"]) is None
|
||||
or not isinstance(value.get("preflightReceiptSha256"), str)
|
||||
or _SHA256.fullmatch(value["preflightReceiptSha256"]) is None
|
||||
or not isinstance(identity, dict)
|
||||
or set(identity)
|
||||
!= {
|
||||
"sha256",
|
||||
"jobId",
|
||||
"observedAt",
|
||||
"revision",
|
||||
"workspaceImageDigest",
|
||||
"frontdoorImageDigest",
|
||||
"migrationCompleted",
|
||||
"healthServices",
|
||||
"controllerArtifact",
|
||||
}
|
||||
or not isinstance(identity.get("sha256"), str)
|
||||
or _SHA256.fullmatch(identity["sha256"]) is None
|
||||
or identity.get("jobId") != job_id
|
||||
or type(identity.get("observedAt")) is not int
|
||||
or identity.get("revision") != value.get("revision")
|
||||
or identity.get("workspaceImageDigest") != value.get("workspaceImageDigest")
|
||||
or identity.get("frontdoorImageDigest") != value.get("frontdoorImageDigest")
|
||||
or identity.get("migrationCompleted") is not True
|
||||
or identity.get("healthServices") != list(_READINESS_SERVICES)
|
||||
or not isinstance(identity.get("controllerArtifact"), dict)
|
||||
or not isinstance(value.get("desired"), dict)
|
||||
or value["desired"] != _desired_state(owner)
|
||||
):
|
||||
return False
|
||||
if hashlib.sha256(
|
||||
_canonical_json({key: item for key, item in identity.items() if key != "sha256"})
|
||||
).hexdigest() != identity["sha256"]:
|
||||
return False
|
||||
artifact = identity["controllerArtifact"]
|
||||
return bool(
|
||||
set(artifact)
|
||||
== {"sourceRepository", "sourceRevision", "sourceTree", "artifactSha256"}
|
||||
and artifact.get("sourceRepository") == "jens/unraid-appops-gateway"
|
||||
and isinstance(artifact.get("sourceRevision"), str)
|
||||
and re.fullmatch(r"[0-9a-f]{40}", artifact["sourceRevision"]) is not None
|
||||
and isinstance(artifact.get("sourceTree"), str)
|
||||
and re.fullmatch(r"[0-9a-f]{40}", artifact["sourceTree"])
|
||||
and isinstance(artifact.get("artifactSha256"), str)
|
||||
and _SHA256.fullmatch(artifact["artifactSha256"]) is not None
|
||||
)
|
||||
|
||||
|
||||
def _load_record(session: Session, job_id: str) -> tuple[str, dict[str, Any] | None]:
|
||||
secret_ref = _journal_secret_ref(job_id)
|
||||
row = session.get(Secret, secret_ref)
|
||||
if row is None:
|
||||
return "missing", None
|
||||
raw = get_secret(session, secret_ref, touch=False)
|
||||
if not isinstance(raw, str) or len(raw) > 16_384:
|
||||
return "invalid", None
|
||||
try:
|
||||
value = json.loads(
|
||||
raw,
|
||||
object_pairs_hook=_reject_duplicate_json_keys,
|
||||
parse_constant=_reject_json_constant,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return "invalid", None
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or _canonical_json(value).decode("utf-8") != raw
|
||||
or not _record_valid(value, job_id)
|
||||
):
|
||||
return "invalid", None
|
||||
return "present", value
|
||||
|
||||
|
||||
def _save_record(session: Session, record: dict[str, Any], *, commit: bool = True) -> bool:
|
||||
try:
|
||||
if not _record_valid(record, record.get("jobId", "")):
|
||||
return False
|
||||
set_secret(
|
||||
session,
|
||||
_journal_secret_ref(record["jobId"]),
|
||||
_canonical_json(record).decode("utf-8"),
|
||||
used_by=[_ACTION],
|
||||
metadata={"kind": "encrypted_forge_route_stage_intent"},
|
||||
commit=commit,
|
||||
)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - secret-store/database errors remain fixed failures.
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _normalized_domain(value: object) -> str | None:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or len(value) > 254
|
||||
or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value)
|
||||
):
|
||||
return None
|
||||
domain = value[:-1] if value.endswith(".") else value
|
||||
if not domain or domain.endswith("."):
|
||||
return None
|
||||
wildcard = domain.startswith("*.")
|
||||
if "*" in domain and not wildcard:
|
||||
return None
|
||||
suffix = domain[2:] if wildcard else domain
|
||||
try:
|
||||
ascii_domain = suffix.encode("idna").decode("ascii").lower()
|
||||
except UnicodeError:
|
||||
return None
|
||||
labels = ascii_domain.split(".")
|
||||
if (
|
||||
not ascii_domain
|
||||
or len(ascii_domain) > 253
|
||||
or any(not label or len(label) > 63 or _DOMAIN_LABEL.fullmatch(label) is None for label in labels)
|
||||
):
|
||||
return None
|
||||
return f"*.{ascii_domain}" if wildcard else ascii_domain
|
||||
|
||||
|
||||
def _domain_matches(value: str) -> bool:
|
||||
normalized = _normalized_domain(value)
|
||||
if normalized == _ROUTE_HOST:
|
||||
return True
|
||||
if normalized is not None and normalized.startswith("*."):
|
||||
suffix = normalized[2:]
|
||||
# Treat any ancestor wildcard as a possible NPM match, including broad
|
||||
# patterns such as *.tech, so route creation fails closed on overlap.
|
||||
return _ROUTE_HOST.endswith(f".{suffix}")
|
||||
return False
|
||||
|
||||
|
||||
def _inventory_matches(value: object) -> tuple[str, list[dict[str, Any]]]:
|
||||
if not isinstance(value, list):
|
||||
return "unknown", []
|
||||
matches: list[dict[str, Any]] = []
|
||||
for host in value:
|
||||
if not isinstance(host, dict):
|
||||
return "unknown", []
|
||||
domains = host.get("domain_names")
|
||||
if not isinstance(domains, list) or any(_normalized_domain(domain) is None for domain in domains):
|
||||
return "unknown", []
|
||||
if any(_domain_matches(domain) for domain in domains):
|
||||
matches.append(host)
|
||||
return "ok", matches
|
||||
|
||||
|
||||
def _host_is_exact_disabled(host: dict[str, Any], record: dict[str, Any]) -> bool:
|
||||
expected = record["desired"]
|
||||
for field in (
|
||||
"forward_scheme",
|
||||
"forward_host",
|
||||
"advanced_config",
|
||||
):
|
||||
if type(host.get(field)) is not str or host[field] != expected[field]:
|
||||
return False
|
||||
for field in (
|
||||
"forward_port",
|
||||
"access_list_id",
|
||||
"certificate_id",
|
||||
):
|
||||
if type(host.get(field)) is not int or host[field] != expected[field]:
|
||||
return False
|
||||
for field in (
|
||||
"enabled",
|
||||
"ssl_forced",
|
||||
"http2_support",
|
||||
"hsts_enabled",
|
||||
"hsts_subdomains",
|
||||
"caching_enabled",
|
||||
"block_exploits",
|
||||
"allow_websocket_upgrade",
|
||||
"trust_forwarded_proto",
|
||||
):
|
||||
if type(host.get(field)) is not bool or host[field] is not expected[field]:
|
||||
return False
|
||||
host_id = host.get("id")
|
||||
if type(host_id) is not int or host_id < 1:
|
||||
return False
|
||||
meta = host.get("meta")
|
||||
return bool(
|
||||
host.get("domain_names") == [_ROUTE_HOST]
|
||||
and isinstance(meta, dict)
|
||||
and meta.get("appops_forge_route_id") == record["ownershipId"]
|
||||
and type(host.get("locations")) is list
|
||||
and host["locations"] == []
|
||||
)
|
||||
|
||||
|
||||
def _inspect_inventory(value: object, record: dict[str, Any] | None) -> str:
|
||||
status, matches = _inventory_matches(value)
|
||||
if status != "ok":
|
||||
return "unknown"
|
||||
if len(matches) > 1:
|
||||
return "conflict"
|
||||
if not matches:
|
||||
return "missing"
|
||||
if record is None:
|
||||
return "conflict"
|
||||
host = matches[0]
|
||||
meta = host.get("meta")
|
||||
if not isinstance(meta, dict) or meta.get("appops_forge_route_id") != record["ownershipId"]:
|
||||
return "conflict"
|
||||
return "staged" if _host_is_exact_disabled(host, record) else "conflict"
|
||||
|
||||
|
||||
def _reconcile_locked(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
record: dict[str, Any],
|
||||
npm: NginxProxyManagerConnector,
|
||||
defer_phase_write: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
endpoint_sha256 = getattr(npm, "endpoint_sha256", None)
|
||||
if (
|
||||
getattr(npm, "verified_tls", None) is not True
|
||||
or not isinstance(endpoint_sha256, str)
|
||||
or _SHA256.fullmatch(endpoint_sha256) is None
|
||||
):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
if endpoint_sha256 != record.get("npmEndpointSha256"):
|
||||
return _result(job_id, "conflict", "forge_route_endpoint_binding_changed")
|
||||
try:
|
||||
hosts = npm.list_forge_route_hosts()
|
||||
except Exception: # noqa: BLE001 - do not expose NPM responses, URLs or credentials.
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
state = _inspect_inventory(hosts, record)
|
||||
if state == "staged":
|
||||
if record.get("phase") != "host_staged_disabled":
|
||||
record["phase"] = "host_staged_disabled"
|
||||
if not defer_phase_write and not _save_record(session, record):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_journal_update_failed")
|
||||
return _result(job_id, "host_staged_disabled", "forge_route_host_staged_disabled_not_routed")
|
||||
if state == "conflict":
|
||||
return _result(job_id, "conflict", "forge_route_ownership_or_state_conflict")
|
||||
if state == "missing" and record.get("phase") == "aborted_before_post":
|
||||
return _result(job_id, "blocked", "forge_route_policy_changed_before_mutation")
|
||||
return _result(job_id, "outcome_unknown", "forge_route_host_state_unconfirmed")
|
||||
|
||||
|
||||
def stage_forge_route(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
idempotency_key: str,
|
||||
actor_identity: str,
|
||||
npm: NginxProxyManagerConnector | None = None,
|
||||
portainer: PortainerConnector | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Stage one disabled, fixed Forge host after current readiness is proven."""
|
||||
normalized_key = idempotency_key.strip() if isinstance(idempotency_key, str) else ""
|
||||
idempotency_hash = hashlib.sha256(normalized_key.encode("utf-8")).hexdigest()
|
||||
actor = actor_identity.strip() if isinstance(actor_identity, str) else ""
|
||||
connector = npm or NginxProxyManagerConnector()
|
||||
network_reader = portainer or PortainerConnector()
|
||||
policy_id: str | None = None
|
||||
decision = "blocked"
|
||||
mutation_attempted = False
|
||||
outcome = _result(job_id, "blocked", "forge_route_stage_requirements_not_met")
|
||||
try:
|
||||
if _JOB_ID.fullmatch(job_id) is None or _IDEMPOTENCY.fullmatch(normalized_key) is None or not actor:
|
||||
outcome = _result(job_id, "blocked", "forge_route_request_invalid")
|
||||
else:
|
||||
with _forge_oidc_process_lock():
|
||||
policy_id = _exact_route_policy_allowed(session)
|
||||
if policy_id is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required")
|
||||
else:
|
||||
decision = "allowed"
|
||||
record_state, record = _load_record(session, job_id)
|
||||
if record_state == "invalid":
|
||||
outcome = _result(job_id, "conflict", "forge_route_journal_invalid")
|
||||
elif record_state == "present" and record is not None:
|
||||
if record["idempotencyKeySha256"] != idempotency_hash:
|
||||
outcome = _result(job_id, "conflict", "forge_route_job_already_claimed")
|
||||
else:
|
||||
route_npm = _forge_route_npm_session(connector)
|
||||
if route_npm is None:
|
||||
outcome = _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
else:
|
||||
outcome = _reconcile_locked(
|
||||
session, job_id=job_id, record=record, npm=route_npm
|
||||
)
|
||||
else:
|
||||
verified = _verified_job(session, job_id)
|
||||
if verified is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_successful_job_required")
|
||||
elif not _secret_store_ready():
|
||||
outcome = _result(job_id, "blocked", "forge_route_encrypted_journal_required")
|
||||
else:
|
||||
job, profile = verified
|
||||
route_npm = _forge_route_npm_session(connector)
|
||||
if route_npm is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_verified_npm_tls_required")
|
||||
else:
|
||||
try:
|
||||
initial_hosts = route_npm.list_forge_route_hosts()
|
||||
except _NpmForgeRoutePermissionDenied:
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"blocked",
|
||||
"forge_route_npm_permission_required",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - no connector diagnostics escape.
|
||||
outcome = _result(job_id, "failed", "forge_route_npm_read_unavailable")
|
||||
else:
|
||||
inventory_state, matches = _inventory_matches(initial_hosts)
|
||||
if inventory_state != "ok":
|
||||
outcome = _result(job_id, "failed", "forge_route_npm_inventory_invalid")
|
||||
elif matches:
|
||||
outcome = _result(job_id, "conflict", "forge_route_existing_host_conflict")
|
||||
elif not _network_ready(network_reader.forge_route_network_readiness()):
|
||||
outcome = _result(job_id, "blocked", "forge_route_shared_network_not_ready")
|
||||
else:
|
||||
readiness_value = ForgeBootstrapApplyService().current_readiness(
|
||||
session, job_id
|
||||
)
|
||||
readiness = _readiness_identity(
|
||||
readiness_value, job=job, profile=profile
|
||||
)
|
||||
if readiness is None:
|
||||
outcome = _result(job_id, "blocked", "forge_route_current_readiness_required")
|
||||
else:
|
||||
# Last authorization check occurs in the existing Forge lock,
|
||||
# after fresh readiness and immediately before the journal/POST.
|
||||
if _exact_route_policy_allowed(session) != policy_id:
|
||||
outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required")
|
||||
else:
|
||||
ownership_id = "froute-" + uuid4().hex
|
||||
record = _build_record(
|
||||
job=job,
|
||||
profile=profile,
|
||||
preflight_receipt=job.preflight_json,
|
||||
idempotency_hash=idempotency_hash,
|
||||
readiness=readiness,
|
||||
ownership_id=ownership_id,
|
||||
npm_endpoint_sha256=route_npm.endpoint_sha256,
|
||||
)
|
||||
if not _save_record(session, record):
|
||||
outcome = _result(job_id, "failed", "forge_route_journal_persist_failed")
|
||||
elif _exact_route_policy_allowed(session) != policy_id:
|
||||
record["phase"] = "aborted_before_post"
|
||||
_save_record(session, record)
|
||||
outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required")
|
||||
elif not _readiness_is_fresh(record["readinessIdentity"]):
|
||||
record["phase"] = "aborted_before_post"
|
||||
_save_record(session, record)
|
||||
outcome = _result(job_id, "blocked", "forge_route_current_readiness_expired")
|
||||
else:
|
||||
mutation_attempted = True
|
||||
try:
|
||||
route_npm.create_forge_disabled_proxy_host(ownership_id)
|
||||
except _NpmForgeRoutePermissionDenied:
|
||||
mutation_attempted = False
|
||||
record["phase"] = "aborted_before_post"
|
||||
if _save_record(session, record):
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"blocked",
|
||||
"forge_route_npm_permission_required",
|
||||
)
|
||||
else:
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"outcome_unknown",
|
||||
"forge_route_journal_update_failed",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a failed POST is ambiguous.
|
||||
record["phase"] = "outcome_unknown"
|
||||
_save_record(session, record)
|
||||
if mutation_attempted:
|
||||
outcome = _reconcile_locked(
|
||||
session,
|
||||
job_id=job_id,
|
||||
record=record,
|
||||
npm=route_npm,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - no lock, connector, or DB detail reaches the caller.
|
||||
outcome = _result(
|
||||
job_id,
|
||||
"outcome_unknown" if mutation_attempted else "failed",
|
||||
"forge_route_operation_unavailable",
|
||||
)
|
||||
|
||||
if not _audit(
|
||||
session,
|
||||
actor_identity=actor or "manual",
|
||||
decision=decision,
|
||||
policy_id=policy_id,
|
||||
result=outcome,
|
||||
idempotency_hash=idempotency_hash,
|
||||
):
|
||||
return _result(
|
||||
job_id,
|
||||
"outcome_unknown" if mutation_attempted else "failed",
|
||||
"forge_route_audit_persist_failed",
|
||||
)
|
||||
return outcome
|
||||
|
||||
|
||||
def reconcile_forge_route(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
actor_identity: str,
|
||||
npm: NginxProxyManagerConnector | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read back the fixed host for an existing operation; this function never POSTs."""
|
||||
connector = npm or NginxProxyManagerConnector()
|
||||
actor = actor_identity.strip() if isinstance(actor_identity, str) else ""
|
||||
if _JOB_ID.fullmatch(job_id) is None:
|
||||
return _result(job_id, "not_found", "forge_route_operation_not_found")
|
||||
if not actor:
|
||||
return _result(job_id, "failed", "forge_route_reconcile_unavailable")
|
||||
try:
|
||||
with _forge_oidc_process_lock():
|
||||
state, record = _load_record(session, job_id)
|
||||
if state == "missing":
|
||||
return _result(job_id, "not_found", "forge_route_operation_not_found")
|
||||
if state != "present" or record is None:
|
||||
return _result(job_id, "outcome_unknown", "forge_route_journal_invalid")
|
||||
route_npm = _forge_route_npm_session(connector)
|
||||
if route_npm is None:
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
previous_phase = record["phase"]
|
||||
result = _reconcile_locked(
|
||||
session,
|
||||
job_id=job_id,
|
||||
record=record,
|
||||
npm=route_npm,
|
||||
defer_phase_write=True,
|
||||
)
|
||||
if result["status"] == "host_staged_disabled" and previous_phase != record["phase"]:
|
||||
if not _save_record(session, record, commit=False):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_journal_update_failed")
|
||||
if not _audit(
|
||||
session,
|
||||
actor_identity=actor,
|
||||
decision="allowed",
|
||||
policy_id=None,
|
||||
result=result,
|
||||
idempotency_hash=record["idempotencyKeySha256"],
|
||||
):
|
||||
return _result(job_id, "outcome_unknown", "forge_route_audit_persist_failed")
|
||||
return result
|
||||
except Exception: # noqa: BLE001 - status remains safe when stores/connectors fail.
|
||||
return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable")
|
||||
@@ -28,6 +28,7 @@ _COMPOSE_CANDIDATES = (
|
||||
)
|
||||
# Must remain identical to DeploymentPlanV1.application_id.
|
||||
_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||
_COMPOSE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
_TRANSIENT_STATES = {"gitea_unavailable", "controller_unavailable", "registration_error"}
|
||||
_RETRY_STATES = _TRANSIENT_STATES | {"controller_conflict", "policy_conflict"}
|
||||
_DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback", "deployment.runnerless.provision")
|
||||
@@ -208,6 +209,7 @@ class GiteaProjectAutoRegistration:
|
||||
"composeFile",
|
||||
"composeProject",
|
||||
"healthTimeout",
|
||||
"primaryService",
|
||||
"verifyOciRevision",
|
||||
}
|
||||
unknown = set(payload) - allowed
|
||||
@@ -223,14 +225,18 @@ class GiteaProjectAutoRegistration:
|
||||
raise ValueError("deployment descriptor applicationId is invalid")
|
||||
compose_file = str(payload.get("composeFile") or "").strip()
|
||||
compose_project = str(payload.get("composeProject") or application_id).strip().lower()
|
||||
if compose_project != application_id:
|
||||
raise ValueError("deployment descriptor composeProject must equal applicationId")
|
||||
if not _COMPOSE_PROJECT.fullmatch(compose_project):
|
||||
raise ValueError("deployment descriptor composeProject is invalid")
|
||||
primary_service = str(payload.get("primaryService") or application_id).strip()
|
||||
if not _APP_ID.fullmatch(primary_service):
|
||||
raise ValueError("deployment descriptor primaryService is invalid")
|
||||
timeout = int(payload.get("healthTimeout") or 180)
|
||||
return {
|
||||
"applicationId": application_id,
|
||||
"composeFile": compose_file,
|
||||
"composeProject": compose_project,
|
||||
"healthTimeout": timeout,
|
||||
"primaryService": primary_service,
|
||||
"verifyOciRevision": bool(payload.get("verifyOciRevision", False)),
|
||||
}
|
||||
|
||||
@@ -303,8 +309,9 @@ class GiteaProjectAutoRegistration:
|
||||
|
||||
if descriptor_present:
|
||||
application_id = str(descriptor["applicationId"])
|
||||
if application_id not in service_names:
|
||||
raise ValueError("deployment descriptor applicationId must name a Compose service")
|
||||
primary_service = str(descriptor["primaryService"])
|
||||
if primary_service not in service_names:
|
||||
raise ValueError("deployment descriptor primaryService must name a Compose service")
|
||||
else:
|
||||
repository_id = self._application_id(repository_name)
|
||||
compose_name = str(compose.get("name") or "").strip().lower() if isinstance(compose, dict) else ""
|
||||
@@ -321,8 +328,9 @@ class GiteaProjectAutoRegistration:
|
||||
# explicit bounded descriptor to select its primary service.
|
||||
return None
|
||||
application_id = candidates[0]
|
||||
primary_service = application_id
|
||||
|
||||
compose_project = application_id
|
||||
compose_project = str(descriptor.get("composeProject") or application_id)
|
||||
containers: list[str] = []
|
||||
for service_name, raw_service in services.items():
|
||||
service = raw_service if isinstance(raw_service, dict) else {}
|
||||
@@ -334,6 +342,7 @@ class GiteaProjectAutoRegistration:
|
||||
"applicationId": application_id,
|
||||
"composeFile": compose_file,
|
||||
"composeProject": compose_project,
|
||||
"primaryService": primary_service,
|
||||
"containers": containers,
|
||||
"serviceNames": service_names,
|
||||
"healthTimeout": int(descriptor.get("healthTimeout") or 180),
|
||||
@@ -510,6 +519,7 @@ class GiteaProjectAutoRegistration:
|
||||
"applicationId": application_id,
|
||||
"composeFile": contract["composeFile"],
|
||||
"composeProject": contract["composeProject"],
|
||||
"primaryService": contract.get("primaryService", application_id),
|
||||
"containers": list(contract["containers"]),
|
||||
"serviceNames": list(contract["serviceNames"]),
|
||||
}
|
||||
|
||||
@@ -337,17 +337,33 @@ def fixed_image_reference(service_name, image_ref):
|
||||
)
|
||||
)
|
||||
if service_name == "postgres":
|
||||
return image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a" or (
|
||||
image_ref == "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04"
|
||||
and main_declares_external_image(image_ref)
|
||||
)
|
||||
if image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a":
|
||||
return True
|
||||
if image_ref in {
|
||||
"postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04",
|
||||
(
|
||||
"gitea.example.com/jens/itworx-mcp-hub/postgres:"
|
||||
"wp400-gosu-v2-20261002-a94e0b4d2029@sha256:"
|
||||
"a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73"
|
||||
),
|
||||
}:
|
||||
return main_declares_external_image(image_ref)
|
||||
return False
|
||||
if service_name == "ingestion-clamd":
|
||||
return image_ref == "clamav/clamav:1.4.6@sha256:a5f03c12a79dbe9f6d8a527b6bb1ea053fa8dd061d3738a26897f055ee2d9303"
|
||||
if service_name == "redis":
|
||||
return image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236" or (
|
||||
image_ref == "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48"
|
||||
and main_declares_external_image(image_ref)
|
||||
)
|
||||
if image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236":
|
||||
return True
|
||||
if image_ref in {
|
||||
"redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48",
|
||||
(
|
||||
"gitea.example.com/jens/itworx-mcp-hub/redis:"
|
||||
"wp400-remediation-20261002-4e18f61c@sha256:"
|
||||
"7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2"
|
||||
),
|
||||
}:
|
||||
return main_declares_external_image(image_ref)
|
||||
return False
|
||||
if service_name == "contextforge":
|
||||
return main_declares_contextforge_compat_image(
|
||||
"itworx-mcp-hub-contextforge-1", image_ref
|
||||
|
||||
@@ -187,6 +187,29 @@ def evaluate_policy(
|
||||
"scope_decision": "allowed" if allowed else None,
|
||||
}
|
||||
|
||||
if action == "forge.route.stage":
|
||||
# This narrowly scoped external-route mutation never inherits the
|
||||
# generic manual fallback or a wildcard grant. The row is seeded
|
||||
# blocked and must be explicitly enabled for the one Forge target.
|
||||
policies = session.exec(select(Policy).where(Policy.action == action)).all()
|
||||
policy = policies[0] if len(policies) == 1 else None
|
||||
allowed = bool(
|
||||
source == "manual"
|
||||
and target == "itworx-forge"
|
||||
and policy is not None
|
||||
and policy.target == "itworx-forge"
|
||||
and policy.manual == "allowed"
|
||||
and policy.voice == "blocked"
|
||||
and policy.risk == "critical"
|
||||
)
|
||||
return {
|
||||
"decision": "allowed" if allowed else "blocked",
|
||||
"reason": "Forge route staging requires one exact-target manual policy; voice is always blocked.",
|
||||
"risk": "critical",
|
||||
"policy_id": policy.id if allowed and policy is not None else None,
|
||||
"scope_decision": "allowed" if allowed else None,
|
||||
}
|
||||
|
||||
if action in _DEFAULT_DECISIONS:
|
||||
reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.')
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user