Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,986 @@
|
||||
"""Bounded enrollment orchestration for a new managed application identity.
|
||||
|
||||
The plane is intentionally not a generic infrastructure API. It creates a
|
||||
single controller target from a trusted profile, generates write-only
|
||||
credentials, and only then creates exact-target deployment policies. Every
|
||||
resource identifier and error returned from this module is sanitized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.time import utc_now
|
||||
from ..db.models import AppSetting, Enrollment, EnrollmentJob, Policy
|
||||
from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1
|
||||
from .audit_logger import log_event
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary
|
||||
from .secret_store import (
|
||||
delete_secret,
|
||||
get_secret,
|
||||
has_secret,
|
||||
secret_storage_status,
|
||||
set_secret,
|
||||
)
|
||||
|
||||
TRUST_SETTING_KEY = "deployment_enrollment:trust"
|
||||
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
_TERMINAL = {"ready", "failed", "compensation_required", "preflight_failed"}
|
||||
_CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "appdataPath"}
|
||||
_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"}
|
||||
_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
|
||||
|
||||
|
||||
class EnrollmentError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _canonical(value: EnrollmentPlanV1) -> tuple[dict[str, Any], str]:
|
||||
payload = value.model_dump(mode="json")
|
||||
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
return payload, digest
|
||||
|
||||
|
||||
def _idempotency_digest(operation: str, value: str) -> str:
|
||||
if not _IDEMPOTENCY_KEY.fullmatch(value):
|
||||
raise EnrollmentError("idempotency key must contain 8 to 128 safe characters")
|
||||
return hashlib.sha256(f"{operation}:{value}".encode()).hexdigest()
|
||||
|
||||
|
||||
def _check(
|
||||
code: str,
|
||||
status: Literal["passed", "blocked", "warning", "unknown"],
|
||||
detail: str,
|
||||
) -> dict[str, str]:
|
||||
return {"code": code, "status": status, "detail": detail}
|
||||
|
||||
|
||||
def _secret_ref(application_id: str, kind: str) -> str:
|
||||
return f"ENROLL_{application_id.upper().replace('-', '_')}_{kind}"
|
||||
|
||||
|
||||
def _credential() -> str:
|
||||
# 64 URL-safe characters provide a high-entropy printable HMAC key while
|
||||
# remaining suitable for the controller's mode-0600 credential file.
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def _fingerprint(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _enrollment_secret_store_ready(storage: dict[str, Any]) -> bool:
|
||||
"""Enrollment always needs a configured production-grade key.
|
||||
|
||||
The general secret store intentionally supports a development fallback for
|
||||
offline UI work. Enrollment generates controller and OIDC credentials, so
|
||||
it must never accept that deterministic fallback as a write target.
|
||||
"""
|
||||
return bool(storage.get("productionReady"))
|
||||
|
||||
|
||||
class EnrollmentPlane:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
broker: DeploymentBrokerClient | None = None,
|
||||
portainer: PortainerConnector | None = None,
|
||||
npm: NginxProxyManagerConnector | None = None,
|
||||
authentik: AuthentikConnector | None = None,
|
||||
):
|
||||
self.broker = broker or DeploymentBrokerClient()
|
||||
self.portainer = portainer or PortainerConnector()
|
||||
self.npm = npm or NginxProxyManagerConnector()
|
||||
self.authentik = authentik or AuthentikConnector()
|
||||
|
||||
@staticmethod
|
||||
def get_trust_config(session: Session) -> EnrollmentTrustConfigV1 | None:
|
||||
row = session.get(AppSetting, TRUST_SETTING_KEY)
|
||||
if not row or row.is_secret:
|
||||
return None
|
||||
try:
|
||||
return EnrollmentTrustConfigV1.model_validate(row.value_json)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set_trust_config(session: Session, value: EnrollmentTrustConfigV1) -> dict[str, Any]:
|
||||
row = session.get(AppSetting, TRUST_SETTING_KEY) or AppSetting(
|
||||
key=TRUST_SETTING_KEY, is_secret=False
|
||||
)
|
||||
row.value_json = value.model_dump(mode="json")
|
||||
row.is_secret = False
|
||||
row.updated_at = utc_now()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return EnrollmentPlane.trust_status(session)
|
||||
|
||||
@staticmethod
|
||||
def trust_status(session: Session) -> dict[str, Any]:
|
||||
trust = EnrollmentPlane.get_trust_config(session)
|
||||
storage = secret_storage_status()
|
||||
if trust is None:
|
||||
return {
|
||||
"status": "needs_setup",
|
||||
"blockers": ["enrollment_trust_not_configured"],
|
||||
"configured": False,
|
||||
"secretStore": storage,
|
||||
}
|
||||
return {
|
||||
"status": "ready" if _enrollment_secret_store_ready(storage) else "blocked",
|
||||
"blockers": [] if _enrollment_secret_store_ready(storage) else ["secret_store_not_production_ready"],
|
||||
"configured": True,
|
||||
"controllerIdentity": trust.controller_identity,
|
||||
"trustedAppdataRoot": trust.trusted_appdata_root,
|
||||
"approvedNetworks": list(trust.approved_networks),
|
||||
"allowedDomainSuffixes": list(trust.allowed_domain_suffixes),
|
||||
"autonomousProfiles": list(trust.autonomous_profiles),
|
||||
"repopulseProxyAccessListId": trust.repopulse_proxy_access_list_id,
|
||||
"oidcConfigured": bool(trust.oidc_issuer and trust.administrator_group),
|
||||
"bootstrapSecretConfigured": has_secret(session, trust.bootstrap_secret_ref),
|
||||
"secretStore": storage,
|
||||
}
|
||||
|
||||
def capabilities(self, session: Session) -> dict[str, Any]:
|
||||
trust = self.get_trust_config(session)
|
||||
storage = secret_storage_status()
|
||||
configured = trust is not None
|
||||
bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref))
|
||||
controller_capability: dict[str, Any] = {}
|
||||
if bootstrap and self.broker.is_configured() and trust is not None:
|
||||
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
|
||||
if bootstrap_secret:
|
||||
controller_capability = self.broker.enrollment_capabilities(secret=bootstrap_secret)
|
||||
broker_ready = bool(
|
||||
bootstrap
|
||||
and self.broker.is_configured()
|
||||
and controller_capability.get("status") == "ready"
|
||||
)
|
||||
npm_ready = self.npm.is_configured()
|
||||
oidc_configured = bool(
|
||||
trust
|
||||
and trust.oidc_issuer
|
||||
and trust.administrator_group
|
||||
and trust.support_agent_group
|
||||
and trust.read_only_group
|
||||
and self.authentik.is_configured()
|
||||
)
|
||||
certificate_ready = bool(npm_ready)
|
||||
|
||||
def item(ready: bool, code: str) -> dict[str, str]:
|
||||
return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code}
|
||||
|
||||
return {
|
||||
"apiVersion": "itworx.enrollment-capabilities/v1",
|
||||
"trust": item(configured, "enrollment_trust_not_configured"),
|
||||
"targetEnrollment": item(broker_ready, "deployment_executor_unavailable"),
|
||||
"secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"),
|
||||
"runtimeDeployment": item(broker_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"),
|
||||
"controller": {
|
||||
"status": str(controller_capability.get("status") or "unavailable"),
|
||||
"reason": str(controller_capability.get("reason") or "deployment_executor_unavailable"),
|
||||
},
|
||||
"profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _allowed_hostname(hostname: str, trust: EnrollmentTrustConfigV1) -> bool:
|
||||
return any(hostname == suffix or hostname.endswith(f".{suffix}") for suffix in trust.allowed_domain_suffixes)
|
||||
|
||||
@staticmethod
|
||||
def _existing(session: Session, application_id: str, repository: str) -> tuple[Enrollment | None, Enrollment | None]:
|
||||
by_application = session.exec(
|
||||
select(Enrollment).where(Enrollment.application_id == application_id)
|
||||
).first()
|
||||
by_repository = session.exec(
|
||||
select(Enrollment).where(Enrollment.repository == repository)
|
||||
).first()
|
||||
return by_application, by_repository
|
||||
|
||||
def _controller_payload(
|
||||
self,
|
||||
plan: EnrollmentPlanV1,
|
||||
*,
|
||||
enrollment_id: str,
|
||||
target_credential: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
profile = profile_contract(plan.profile)
|
||||
assert profile is not None # validated before this method is called
|
||||
payload: dict[str, Any] = {
|
||||
"api_version": "itworx.controller-enrollment/v1",
|
||||
"enrollment_id": enrollment_id,
|
||||
"application_id": plan.application_id,
|
||||
"repository": plan.repository,
|
||||
"revision": plan.revision,
|
||||
"profile": plan.profile,
|
||||
"container_name": plan.target.container_name,
|
||||
"compose_file": plan.target.compose_file,
|
||||
"appdata_path": plan.appdata_path,
|
||||
"data_path": plan.data_path,
|
||||
"runtime_uid": plan.storage.runtime_uid,
|
||||
"runtime_gid": plan.storage.runtime_gid,
|
||||
"network": plan.network.docker_network,
|
||||
"internal_port": plan.network.internal_port,
|
||||
"proxy_hostname": plan.exposure.hostname if plan.exposure else None,
|
||||
"image_repository": plan.artifact.image_repository,
|
||||
"image_tag": plan.artifact.image_tag,
|
||||
"health_path": plan.artifact.health_path,
|
||||
"runtime_environment_keys": list(profile["runtimeEnvironmentKeys"]),
|
||||
}
|
||||
if target_credential is not None:
|
||||
payload["target_credential"] = target_credential
|
||||
return payload
|
||||
|
||||
def preflight(self, session: Session, plan: EnrollmentPlanV1) -> dict[str, Any]:
|
||||
checks: list[dict[str, str]] = []
|
||||
trust = self.get_trust_config(session)
|
||||
profile = profile_contract(plan.profile)
|
||||
if trust is None:
|
||||
checks.append(_check("enrollment_trust_not_configured", "blocked", "Root enrollment trust boundaries are not configured."))
|
||||
else:
|
||||
checks.append(_check("enrollment_trust", "passed", "Root-owned enrollment boundaries are configured."))
|
||||
if plan.target.target_identity == trust.controller_identity:
|
||||
checks.append(_check("controller_identity", "passed", "Requested controller matches the trusted enrollment target."))
|
||||
else:
|
||||
checks.append(_check("deployment_executor_unavailable", "blocked", "Requested controller is not the configured enrollment target."))
|
||||
if plan.network.docker_network in trust.approved_networks:
|
||||
checks.append(_check("network", "passed", "Requested Docker network is within the trusted profile boundary."))
|
||||
else:
|
||||
checks.append(_check("network_unavailable", "blocked", "Requested Docker network is not approved for enrollment."))
|
||||
if plan.profile in trust.autonomous_profiles:
|
||||
checks.append(_check("autonomous_profile", "passed", "Deployment profile is approved for autonomous enrollment."))
|
||||
else:
|
||||
checks.append(_check("policy_template_unavailable", "blocked", "Deployment profile is not approved for autonomous enrollment."))
|
||||
|
||||
if profile is None:
|
||||
checks.append(_check("policy_template_unavailable", "blocked", "Requested server-side deployment profile is unavailable."))
|
||||
else:
|
||||
checks.append(_check("deployment_profile", "passed", "Server-side deployment profile is available."))
|
||||
|
||||
if plan.profile == "repopulse-authenticated":
|
||||
gitea_token = get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False)
|
||||
checks.append(_check(
|
||||
"repopulse_gitea_token_unavailable",
|
||||
"passed" if gitea_token else "blocked",
|
||||
"The existing encrypted RepoPulse Gitea token is available by reference."
|
||||
if gitea_token else "The existing encrypted RepoPulse Gitea token is unavailable.",
|
||||
))
|
||||
if has_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN")):
|
||||
checks.append(_check(
|
||||
"repopulse_maintenance_token_collision", "blocked",
|
||||
"An existing write-only RepoPulse maintenance token cannot be adopted by a new enrollment.",
|
||||
))
|
||||
else:
|
||||
checks.append(_check(
|
||||
"repopulse_maintenance_token", "passed",
|
||||
"A new write-only RepoPulse maintenance token can be generated for this enrollment.",
|
||||
))
|
||||
if trust is None or trust.repopulse_proxy_access_list_id is None:
|
||||
checks.append(_check(
|
||||
"repopulse_access_list_unavailable", "blocked",
|
||||
"A positive root-owned RepoPulse NPM access-list ID is not configured.",
|
||||
))
|
||||
else:
|
||||
verify_access_list = getattr(self.npm, "validate_managed_access_list", None)
|
||||
access_list = verify_access_list(trust.repopulse_proxy_access_list_id) if callable(verify_access_list) else {}
|
||||
verified = (
|
||||
access_list.get("success") is True
|
||||
and access_list.get("status") == "verified"
|
||||
and access_list.get("accessListId") == trust.repopulse_proxy_access_list_id
|
||||
)
|
||||
checks.append(_check(
|
||||
"repopulse_access_list_unavailable",
|
||||
"passed" if verified else "blocked",
|
||||
"The root-owned RepoPulse NPM access list has active authorization entries and requires all checks."
|
||||
if verified else "The root-owned RepoPulse NPM access list could not be verified.",
|
||||
))
|
||||
|
||||
by_application, by_repository = self._existing(session, plan.application_id, plan.repository)
|
||||
if by_application:
|
||||
status = "warning" if by_application.plan_sha256 == _canonical(plan)[1] else "blocked"
|
||||
code = "enrollment_replay" if status == "warning" else "target_already_exists"
|
||||
checks.append(_check(code, status, "This application identity is already enrolled."))
|
||||
else:
|
||||
checks.append(_check("application_identity", "passed", "Application identity is not yet enrolled."))
|
||||
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."))
|
||||
|
||||
containers = self.portainer.list_containers()
|
||||
if containers.get("status") != "ok":
|
||||
checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection."))
|
||||
else:
|
||||
names = {str(item.get("name") or "").casefold() for item in containers.get("containers", [])}
|
||||
if plan.target.container_name.casefold() in names:
|
||||
checks.append(_check("unmanaged_resource_collision", "blocked", "Container identity already exists and cannot be claimed by enrollment."))
|
||||
else:
|
||||
checks.append(_check("container_identity", "passed", "Container identity is free."))
|
||||
|
||||
if plan.exposure:
|
||||
if trust and self._allowed_hostname(plan.exposure.hostname, trust):
|
||||
checks.append(_check("hostname_suffix", "passed", "Hostname is inside an allowed domain suffix."))
|
||||
else:
|
||||
checks.append(_check("hostname_not_allowed", "blocked", "Hostname is outside the configured allowed suffixes."))
|
||||
hosts = self.npm.proxy_hosts()
|
||||
if not hosts.get("success"):
|
||||
checks.append(_check("npm_credentials_missing" if not self.npm.is_configured() else "npm_executor_unavailable", "blocked", "NPM inventory is unavailable for takeover protection."))
|
||||
else:
|
||||
existing = [item for item in hosts.get("items", []) if plan.exposure.hostname in {str(value).lower() for value in item.get("domainNames", [])}]
|
||||
checks.append(_check("hostname_collision", "blocked" if existing else "passed", "Hostname is already owned and cannot be taken over." if existing else "Hostname is free in NPM."))
|
||||
|
||||
if plan.authentication.mode == "native-oidc":
|
||||
groups_ready = bool(trust and trust.administrator_group and trust.support_agent_group and trust.read_only_group and trust.oidc_issuer)
|
||||
if not groups_ready or not self.authentik.is_configured():
|
||||
checks.append(_check("oidc_profile_unavailable", "blocked", "Configured OIDC profile or Authentik role groups are unavailable."))
|
||||
else:
|
||||
assert trust is not None and plan.authentication.roles is not None
|
||||
expected_roles = {
|
||||
"administrator": trust.administrator_group,
|
||||
"support_agent": trust.support_agent_group,
|
||||
"read_only": trust.read_only_group,
|
||||
}
|
||||
requested_roles = plan.authentication.roles.model_dump(mode="json")
|
||||
checks.append(_check(
|
||||
"oidc_role_model",
|
||||
"passed" if requested_roles == expected_roles else "blocked",
|
||||
"Requested application roles match the root-owned Authentik role model."
|
||||
if requested_roles == expected_roles
|
||||
else "Requested application roles do not match the root-owned Authentik role model.",
|
||||
))
|
||||
auth_status = self.authentik.test_connection()
|
||||
if auth_status.get("status") != "connected":
|
||||
checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable."))
|
||||
else:
|
||||
template = self.authentik.deployment_oidc_template_status(
|
||||
role_claim=trust.oidc_role_claim
|
||||
)
|
||||
if template.get("status") != "ready":
|
||||
checks.append(_check("oidc_profile_unavailable", "blocked", "The server-owned Authentik OIDC template is incomplete."))
|
||||
applications = self.authentik.applications()
|
||||
groups = self.authentik.groups()
|
||||
if not applications.get("success") or not groups.get("success"):
|
||||
checks.append(_check("oidc_provider", "blocked", "Authentik applications cannot be inspected for takeover protection."))
|
||||
elif any(str(item.get("slug") or "") == plan.application_id for item in applications.get("items", [])):
|
||||
checks.append(_check("unmanaged_resource_collision", "blocked", "An existing Authentik application cannot be claimed by a new enrollment."))
|
||||
else:
|
||||
checks.append(_check("oidc_provider", "passed", "Authentik is available and the application identity is free."))
|
||||
required_groups = {
|
||||
trust.administrator_group if trust else "",
|
||||
trust.support_agent_group if trust else "",
|
||||
trust.read_only_group if trust else "",
|
||||
}
|
||||
found_groups = {str(item.get("name") or "") for item in groups.get("items", [])}
|
||||
checks.append(_check(
|
||||
"oidc_role_groups",
|
||||
"passed" if required_groups.issubset(found_groups) else "blocked",
|
||||
"Configured Authentik role groups are available." if required_groups.issubset(found_groups) else "One or more configured Authentik role groups are missing.",
|
||||
))
|
||||
|
||||
storage = secret_storage_status()
|
||||
storage_ready = _enrollment_secret_store_ready(storage)
|
||||
checks.append(_check("secret_store", "passed" if storage_ready else "blocked", "Production-grade write-only secret storage is ready." if storage_ready else "Enrollment requires APPOPS_SECRET_KEY from a protected environment variable or file; the development fallback is not permitted."))
|
||||
if trust:
|
||||
checks.append(_check("controller_bootstrap_secret", "passed" if has_secret(session, trust.bootstrap_secret_ref) else "blocked", "Controller bootstrap secret is available by reference." if has_secret(session, trust.bootstrap_secret_ref) else "Controller bootstrap secret reference is not configured."))
|
||||
if has_secret(session, trust.bootstrap_secret_ref):
|
||||
controller = self.broker.enrollment_preflight(
|
||||
self._controller_payload(plan, enrollment_id="enr-00000000000000000000"),
|
||||
secret=get_secret(session, trust.bootstrap_secret_ref, touch=False) or "",
|
||||
)
|
||||
code = str(controller.get("reason") or "deployment_executor_unavailable")
|
||||
checks.append(_check("controller_target", "passed" if controller.get("status") in {"preflight_ok", "ready"} else "blocked", "Controller accepted the bounded enrollment preflight." if controller.get("status") in {"preflight_ok", "ready"} else f"Controller enrollment preflight is unavailable ({code})."))
|
||||
else:
|
||||
checks.append(_check("controller_target", "blocked", "Controller bootstrap credential is unavailable."))
|
||||
|
||||
blockers = [item["code"] for item in checks if item["status"] == "blocked"]
|
||||
warnings = [item["code"] for item in checks if item["status"] == "warning"]
|
||||
planned_resources = {
|
||||
"applicationId": plan.application_id,
|
||||
"controllerTarget": plan.target.target_identity,
|
||||
"appdataPath": plan.appdata_path,
|
||||
"dataPath": plan.data_path,
|
||||
"network": plan.network.docker_network,
|
||||
"hostname": plan.exposure.hostname if plan.exposure else None,
|
||||
"oidc": plan.authentication.mode == "native-oidc",
|
||||
}
|
||||
return {
|
||||
"status": "ready" if not blockers else "blocked",
|
||||
"blockers": blockers,
|
||||
"warnings": warnings,
|
||||
"checks": checks,
|
||||
"plannedResources": planned_resources,
|
||||
"plannedMutations": [] if blockers else ["controller_target_register", "secret_generate", *( ["reverse_proxy_ensure"] if plan.exposure else []), *( ["oidc_application_ensure"] if plan.authentication.mode == "native-oidc" else []), *( ["runtime_environment_seal"] if profile and profile["runtimeEnvironmentKeys"] else []), "exact_policy_install"],
|
||||
"plannedSecretRefs": [
|
||||
_secret_ref(plan.application_id, "TARGET_HMAC"),
|
||||
*( [_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET")] if plan.authentication.mode == "native-oidc" else []),
|
||||
*( ["REPOPULSE_GITEA_TOKEN", _secret_ref("repopulse", "MAINTENANCE_TOKEN")] if plan.profile == "repopulse-authenticated" else []),
|
||||
],
|
||||
"plannedPolicies": [f"enrollment-{plan.application_id}-deployment-apply", f"enrollment-{plan.application_id}-deployment-rollback"],
|
||||
}
|
||||
|
||||
def _create_enrollment(self, session: Session, plan: EnrollmentPlanV1, plan_sha256: str, trust: EnrollmentTrustConfigV1) -> Enrollment:
|
||||
enrollment = Enrollment(
|
||||
id=f"enr-{uuid.uuid4().hex[:20]}",
|
||||
application_id=plan.application_id,
|
||||
repository=plan.repository,
|
||||
revision=plan.revision,
|
||||
profile=plan.profile,
|
||||
controller_identity=trust.controller_identity,
|
||||
plan_sha256=plan_sha256,
|
||||
status="planned",
|
||||
target_secret_ref=_secret_ref(plan.application_id, "TARGET_HMAC"),
|
||||
oidc_secret_ref=_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET") if plan.authentication.mode == "native-oidc" else None,
|
||||
plan_json=plan.model_dump(mode="json"),
|
||||
provenance_json={"profileVersion": PROFILE_VERSION, "createdBy": "enrollment-plane/v1", "sourceRevision": plan.revision},
|
||||
)
|
||||
session.add(enrollment)
|
||||
session.commit()
|
||||
session.refresh(enrollment)
|
||||
return enrollment
|
||||
|
||||
def _create_job(self, session: Session, enrollment: Enrollment, idempotency_key: str) -> EnrollmentJob:
|
||||
digest = _idempotency_digest("enroll", idempotency_key)
|
||||
existing = session.exec(select(EnrollmentJob).where(EnrollmentJob.idempotency_sha256 == digest)).first()
|
||||
if existing:
|
||||
if existing.enrollment_id != enrollment.id:
|
||||
raise EnrollmentError("idempotency key was already used for a different enrollment")
|
||||
return existing
|
||||
job = EnrollmentJob(
|
||||
id=f"enroll-{uuid.uuid4().hex[:16]}",
|
||||
enrollment_id=enrollment.id,
|
||||
application_id=enrollment.application_id,
|
||||
idempotency_sha256=digest,
|
||||
status="queued",
|
||||
stage="planned",
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
return job
|
||||
|
||||
@staticmethod
|
||||
def serialize_enrollment(enrollment: Enrollment) -> dict[str, Any]:
|
||||
plan = dict(enrollment.plan_json or {})
|
||||
exposure = plan.get("exposure") if isinstance(plan.get("exposure"), dict) else {}
|
||||
authentication = plan.get("authentication") if isinstance(plan.get("authentication"), dict) else {}
|
||||
return {
|
||||
"id": enrollment.id,
|
||||
"applicationId": enrollment.application_id,
|
||||
"repository": enrollment.repository,
|
||||
"revision": enrollment.revision,
|
||||
"profile": enrollment.profile,
|
||||
"controllerIdentity": enrollment.controller_identity,
|
||||
"status": enrollment.status,
|
||||
"publicUrl": f"https://{exposure.get('hostname')}" if exposure.get("hostname") else None,
|
||||
"authentication": authentication.get("mode", "none"),
|
||||
"resources": enrollment.resources_json or {},
|
||||
"targetSecretRef": enrollment.target_secret_ref,
|
||||
"oidcSecretRef": enrollment.oidc_secret_ref,
|
||||
"maintenanceSecretRef": (
|
||||
_secret_ref("repopulse", "MAINTENANCE_TOKEN")
|
||||
if enrollment.profile == "repopulse-authenticated" else None
|
||||
),
|
||||
"lastVerifiedAt": enrollment.last_verified_at.isoformat() if enrollment.last_verified_at else None,
|
||||
"createdAt": enrollment.created_at.isoformat(),
|
||||
"updatedAt": enrollment.updated_at.isoformat(),
|
||||
"completedAt": enrollment.completed_at.isoformat() if enrollment.completed_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def serialize_job(job: EnrollmentJob, *, replay: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"id": job.id,
|
||||
"enrollmentId": job.enrollment_id,
|
||||
"applicationId": job.application_id,
|
||||
"operation": job.operation,
|
||||
"status": job.status,
|
||||
"stage": job.stage,
|
||||
"preflight": job.preflight_json or {},
|
||||
"evidence": job.evidence_json or {},
|
||||
"result": job.result_summary,
|
||||
"createdAt": job.created_at.isoformat(),
|
||||
"updatedAt": job.updated_at.isoformat(),
|
||||
"completedAt": job.completed_at.isoformat() if job.completed_at else None,
|
||||
"idempotentReplay": replay,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _checkpoint(
|
||||
session: Session,
|
||||
enrollment: Enrollment,
|
||||
job: EnrollmentJob,
|
||||
*,
|
||||
stage: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
"""Durably record intent and receipts around every external boundary.
|
||||
|
||||
This is deliberately conservative: after an interrupted external call,
|
||||
a same-plan request is surfaced as recovery-required instead of being
|
||||
replayed against NPM, Authentik or the controller without proof of its
|
||||
prior effect. Values in ``evidence`` are identifiers/status only.
|
||||
"""
|
||||
now = utc_now()
|
||||
enrollment.status = "provisioning"
|
||||
enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence}
|
||||
enrollment.updated_at = now
|
||||
job.status = "running"
|
||||
job.stage = stage
|
||||
job.evidence_json = {**dict(job.evidence_json or {}), **evidence}
|
||||
job.updated_at = now
|
||||
session.add(enrollment)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
def _install_policies(self, session: Session, enrollment: Enrollment, plan: EnrollmentPlanV1) -> list[str]:
|
||||
provenance = {
|
||||
"enrollmentId": enrollment.id,
|
||||
"profileVersion": PROFILE_VERSION,
|
||||
"createdBy": "enrollment-plane/v1",
|
||||
"policyTemplateVersion": "exact-target/v1",
|
||||
"target": plan.application_id,
|
||||
"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(
|
||||
id=policy_id,
|
||||
action=action,
|
||||
target=plan.application_id,
|
||||
voice="blocked",
|
||||
manual="allowed",
|
||||
risk="high",
|
||||
reason="Enrollment-generated exact-target deployment policy.",
|
||||
)
|
||||
existing.provenance_json = provenance
|
||||
session.add(existing)
|
||||
identifiers.append(policy_id)
|
||||
session.commit()
|
||||
return identifiers
|
||||
|
||||
def _compensate(
|
||||
self,
|
||||
session: Session,
|
||||
enrollment: Enrollment,
|
||||
evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Reverse only resources this execution proved it created.
|
||||
|
||||
A failed compensation stays visible as ``compensation_required``. It
|
||||
never attempts to adopt, overwrite or delete an earlier resource.
|
||||
"""
|
||||
plan = EnrollmentPlanV1.model_validate(enrollment.plan_json)
|
||||
outcomes: dict[str, str] = {}
|
||||
oidc = evidence.get("oidc") if isinstance(evidence.get("oidc"), dict) else {}
|
||||
if oidc.get("status") == "created":
|
||||
remove = getattr(self.authentik, "remove_deployment_oidc_application", None)
|
||||
result = remove(
|
||||
application_id=oidc.get("applicationId"),
|
||||
provider_id=oidc.get("providerId"),
|
||||
application_slug=plan.application_id,
|
||||
launch_url=plan.public_url or "",
|
||||
) if callable(remove) else {"success": False, "status": "unsupported"}
|
||||
outcomes["oidc"] = str(result.get("status") or "failed")
|
||||
elif oidc.get("status") == "partial_failure":
|
||||
outcomes["oidc"] = "ownership_unverified"
|
||||
|
||||
proxy = evidence.get("proxy") if isinstance(evidence.get("proxy"), dict) else {}
|
||||
if proxy.get("status") == "created":
|
||||
remove = getattr(self.npm, "remove_managed_proxy_host", None)
|
||||
result = remove(host_id=proxy.get("hostId"), ownership_id=enrollment.id) if callable(remove) else {"success": False, "status": "unsupported"}
|
||||
outcomes["proxy"] = str(result.get("status") or "failed")
|
||||
elif proxy.get("status") == "ownership_unverified":
|
||||
outcomes["proxy"] = "ownership_unverified"
|
||||
|
||||
controller = evidence.get("controller") if isinstance(evidence.get("controller"), dict) else {}
|
||||
if controller.get("status") in {"enrolled", "already_enrolled"}:
|
||||
trust = self.get_trust_config(session)
|
||||
bootstrap = get_secret(session, trust.bootstrap_secret_ref, touch=False) if trust else None
|
||||
if bootstrap:
|
||||
result = self.broker.unenroll_target(
|
||||
{"enrollment_id": enrollment.id, "application_id": enrollment.application_id, "repository": enrollment.repository},
|
||||
secret=bootstrap,
|
||||
)
|
||||
outcomes["controller"] = str(result.get("status") or "failed")
|
||||
else:
|
||||
outcomes["controller"] = "bootstrap_secret_unavailable"
|
||||
elif controller.get("status") == "partial_failure":
|
||||
outcomes["controller"] = "ownership_unverified"
|
||||
|
||||
completed = all(value in {"removed", "rolled_back"} for value in outcomes.values())
|
||||
if completed and outcomes:
|
||||
delete_secret(session, enrollment.target_secret_ref)
|
||||
if enrollment.oidc_secret_ref:
|
||||
delete_secret(session, enrollment.oidc_secret_ref)
|
||||
if plan.profile == "repopulse-authenticated":
|
||||
delete_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN"))
|
||||
return {"completed": completed, "outcomes": outcomes}
|
||||
|
||||
def _fail(
|
||||
self,
|
||||
session: Session,
|
||||
enrollment: Enrollment,
|
||||
job: EnrollmentJob,
|
||||
*,
|
||||
stage: str,
|
||||
message: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
compensation = (
|
||||
self._compensate(session, enrollment, evidence)
|
||||
if evidence.get("controller")
|
||||
else {"completed": True, "outcomes": {}}
|
||||
)
|
||||
compensation_required = not bool(compensation.get("completed"))
|
||||
enrollment.status = "compensation_required" if compensation_required else "failed"
|
||||
enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence, "compensation": compensation}
|
||||
enrollment.updated_at = utc_now()
|
||||
enrollment.completed_at = utc_now()
|
||||
job.status = "compensation_required" if compensation_required else "failed"
|
||||
job.stage = stage
|
||||
job.evidence_json = {**dict(job.evidence_json or {}), **evidence, "compensation": compensation}
|
||||
job.result_summary = message
|
||||
job.updated_at = utc_now()
|
||||
job.completed_at = utc_now()
|
||||
session.add(enrollment)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
log_event(
|
||||
session,
|
||||
source="enrollment-plane",
|
||||
identity="operator",
|
||||
action_id="target.enroll",
|
||||
target=enrollment.application_id,
|
||||
target_type="enrollment",
|
||||
risk="high",
|
||||
policy_decision="blocked",
|
||||
reason=message,
|
||||
result="failed",
|
||||
execution_plan={"enrollmentId": enrollment.id, "planSha256": enrollment.plan_sha256},
|
||||
after_state={"stage": stage, "status": enrollment.status},
|
||||
)
|
||||
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
|
||||
|
||||
def enroll(self, session: Session, plan: EnrollmentPlanV1, *, idempotency_key: str) -> dict[str, Any]:
|
||||
_, plan_digest = _canonical(plan)
|
||||
existing, repository_existing = self._existing(session, plan.application_id, plan.repository)
|
||||
if existing and existing.plan_sha256 == plan_digest:
|
||||
prior = session.exec(
|
||||
select(EnrollmentJob).where(EnrollmentJob.enrollment_id == existing.id).order_by(EnrollmentJob.created_at.desc())
|
||||
).first()
|
||||
if prior is None or prior.status in _TERMINAL:
|
||||
return {
|
||||
"enrollment": self.serialize_enrollment(existing),
|
||||
"job": self.serialize_job(prior, replay=True) if prior else None,
|
||||
"idempotentReplay": True,
|
||||
}
|
||||
# A process may have stopped after an external request was sent but
|
||||
# before its response was durably checkpointed. Do not infer the
|
||||
# effect and do not send the request again: retain the exact intent
|
||||
# and require an explicit reconciliation flow.
|
||||
existing.status = "recovery_required"
|
||||
existing.updated_at = utc_now()
|
||||
prior.status = "recovery_required"
|
||||
prior.result_summary = "Enrollment stopped during an external stage; reconcile recorded intent and receipts before retrying."
|
||||
prior.updated_at = utc_now()
|
||||
session.add(existing)
|
||||
session.add(prior)
|
||||
session.commit()
|
||||
return {
|
||||
"enrollment": self.serialize_enrollment(existing),
|
||||
"job": self.serialize_job(prior),
|
||||
"recoveryRequired": True,
|
||||
}
|
||||
if existing or repository_existing:
|
||||
raise EnrollmentError("application identity or repository is already enrolled")
|
||||
|
||||
trust = self.get_trust_config(session)
|
||||
if trust is None:
|
||||
raise EnrollmentError("enrollment trust boundaries are not configured")
|
||||
enrollment = self._create_enrollment(session, plan, plan_digest, trust)
|
||||
job = self._create_job(session, enrollment, idempotency_key)
|
||||
if job.status != "queued":
|
||||
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job, replay=True)}
|
||||
|
||||
preflight = self.preflight(session, plan)
|
||||
job.preflight_json = preflight
|
||||
job.updated_at = utc_now()
|
||||
session.add(job)
|
||||
session.commit()
|
||||
if preflight["status"] != "ready":
|
||||
enrollment.status = "preflight_failed"
|
||||
enrollment.completed_at = utc_now()
|
||||
enrollment.updated_at = utc_now()
|
||||
job.status = "preflight_failed"
|
||||
job.stage = "preflight"
|
||||
job.result_summary = "Enrollment preflight was blocked."
|
||||
job.completed_at = utc_now()
|
||||
session.add(enrollment)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
|
||||
|
||||
repopulse_gitea_token = (
|
||||
get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False)
|
||||
if plan.profile == "repopulse-authenticated" else None
|
||||
)
|
||||
if plan.profile == "repopulse-authenticated" and not repopulse_gitea_token:
|
||||
return self._fail(
|
||||
session, enrollment, job, stage="preflight",
|
||||
message="The existing encrypted RepoPulse Gitea token became unavailable after preflight.",
|
||||
evidence={},
|
||||
)
|
||||
|
||||
target_secret = _credential()
|
||||
set_secret(
|
||||
session,
|
||||
enrollment.target_secret_ref,
|
||||
target_secret,
|
||||
used_by=[f"enrollment:{enrollment.id}", "deployment-controller"],
|
||||
metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(target_secret), "rotationState": "active"},
|
||||
)
|
||||
oidc_secret: str | None = None
|
||||
if enrollment.oidc_secret_ref:
|
||||
oidc_secret = _credential()
|
||||
set_secret(
|
||||
session,
|
||||
enrollment.oidc_secret_ref,
|
||||
oidc_secret,
|
||||
used_by=[f"enrollment:{enrollment.id}", "authentik", "deployment-controller"],
|
||||
metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(oidc_secret), "rotationState": "active"},
|
||||
)
|
||||
repopulse_maintenance_token: str | None = None
|
||||
if plan.profile == "repopulse-authenticated":
|
||||
repopulse_maintenance_token = _credential()
|
||||
set_secret(
|
||||
session,
|
||||
_secret_ref("repopulse", "MAINTENANCE_TOKEN"),
|
||||
repopulse_maintenance_token,
|
||||
used_by=[f"enrollment:{enrollment.id}", "repopulse-maintenance", "deployment-controller"],
|
||||
metadata={
|
||||
"enrollmentId": enrollment.id,
|
||||
"credentialFingerprint": _fingerprint(repopulse_maintenance_token),
|
||||
"rotationState": "active",
|
||||
},
|
||||
)
|
||||
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
|
||||
if not bootstrap_secret:
|
||||
return self._fail(session, enrollment, job, stage="creating_secrets", message="Controller bootstrap secret disappeared after preflight.", evidence={})
|
||||
evidence: dict[str, Any] = {
|
||||
"intent": {
|
||||
"controller": {
|
||||
"enrollmentId": enrollment.id,
|
||||
"applicationId": plan.application_id,
|
||||
"repository": plan.repository,
|
||||
}
|
||||
}
|
||||
}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="registering_target", evidence=evidence
|
||||
)
|
||||
controller_result = self.broker.enroll_target(
|
||||
self._controller_payload(plan, enrollment_id=enrollment.id, target_credential=target_secret),
|
||||
secret=bootstrap_secret,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
evidence["controller"] = {
|
||||
key: controller_result[key] for key in _CONTROLLER_EVIDENCE if key in controller_result
|
||||
}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="registering_target", evidence=evidence
|
||||
)
|
||||
if controller_result.get("status") not in {"enrolled", "already_enrolled"}:
|
||||
return self._fail(session, enrollment, job, stage="registering_target", message="Controller rejected the bounded target enrollment.", evidence=evidence)
|
||||
|
||||
if plan.exposure:
|
||||
evidence["intent"] = {
|
||||
**dict(evidence.get("intent") or {}),
|
||||
"proxy": {"hostname": plan.exposure.hostname, "ownershipId": enrollment.id},
|
||||
}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="provisioning_proxy", evidence=evidence
|
||||
)
|
||||
proxy_arguments = {
|
||||
"hostname": plan.exposure.hostname,
|
||||
"forward_host": plan.application_id,
|
||||
"forward_port": plan.network.internal_port,
|
||||
"certificate_mode": "managed",
|
||||
"force_ssl": True,
|
||||
"http2": True,
|
||||
"upload_limit_mb": 1024 if plan.exposure.upload_profile == "large-upload" else 128,
|
||||
"read_timeout_seconds": 900 if plan.exposure.timeout_profile == "long-running" else 120,
|
||||
"ownership_id": enrollment.id,
|
||||
}
|
||||
if plan.profile == "repopulse-authenticated":
|
||||
access_list_id = trust.repopulse_proxy_access_list_id if trust else None
|
||||
if not access_list_id or access_list_id <= 0:
|
||||
return self._fail(
|
||||
session, enrollment, job,
|
||||
stage="provisioning_proxy",
|
||||
message="RepoPulse requires a verified, positive NPM access-list ID.",
|
||||
evidence=evidence,
|
||||
)
|
||||
proxy_arguments["access_list_id"] = access_list_id
|
||||
proxy_result = self.npm.ensure_managed_proxy_host(
|
||||
**proxy_arguments,
|
||||
)
|
||||
evidence["proxy"] = {key: proxy_result[key] for key in _PROXY_EVIDENCE if key in proxy_result}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="provisioning_proxy", evidence=evidence
|
||||
)
|
||||
if not proxy_result.get("success") or proxy_result.get("status") != "created":
|
||||
return self._fail(session, enrollment, job, stage="provisioning_proxy", message="Managed reverse-proxy provisioning was not verified.", evidence=evidence)
|
||||
|
||||
profile = profile_contract(plan.profile)
|
||||
runtime_values: dict[str, str] = {}
|
||||
if plan.public_url and profile and "APPOPS_PUBLIC_URL" in profile["runtimeEnvironmentKeys"]:
|
||||
runtime_values["APPOPS_PUBLIC_URL"] = plan.public_url
|
||||
if plan.authentication.mode == "native-oidc":
|
||||
assert oidc_secret is not None and enrollment.oidc_secret_ref is not None
|
||||
evidence["intent"] = {
|
||||
**dict(evidence.get("intent") or {}),
|
||||
"oidc": {"applicationSlug": plan.application_id, "launchUrl": plan.public_url},
|
||||
}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="provisioning_identity", evidence=evidence
|
||||
)
|
||||
oidc_result = self.authentik.ensure_deployment_oidc_application(
|
||||
application_slug=plan.application_id,
|
||||
launch_url=plan.public_url or "",
|
||||
redirect_uri=f"{plan.public_url}{plan.authentication.redirect_path}",
|
||||
client_secret=oidc_secret,
|
||||
administrator_group=trust.administrator_group or "",
|
||||
support_agent_group=trust.support_agent_group or "",
|
||||
read_only_group=trust.read_only_group or "",
|
||||
)
|
||||
evidence["oidc"] = {key: oidc_result[key] for key in _OIDC_EVIDENCE if key in oidc_result}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="provisioning_identity", evidence=evidence
|
||||
)
|
||||
if not oidc_result.get("success") or oidc_result.get("status") != "created":
|
||||
return self._fail(session, enrollment, job, stage="provisioning_identity", message="Bounded OIDC application provisioning was not verified.", evidence=evidence)
|
||||
runtime_values.update({
|
||||
"OIDC_CLIENT_ID": plan.application_id,
|
||||
"OIDC_CLIENT_SECRET": oidc_secret,
|
||||
"OIDC_ISSUER": trust.oidc_issuer or "",
|
||||
"OIDC_ROLE_CLAIM": trust.oidc_role_claim,
|
||||
})
|
||||
if plan.profile == "repopulse-authenticated":
|
||||
assert repopulse_gitea_token is not None and repopulse_maintenance_token is not None
|
||||
runtime_values.update({
|
||||
"REPOPULSE_GITEA_URL": "https://gitea.example.com",
|
||||
"REPOPULSE_GITEA_TOKEN": repopulse_gitea_token,
|
||||
"REPOPULSE_MAINTENANCE_TOKEN": repopulse_maintenance_token,
|
||||
})
|
||||
|
||||
if profile and profile["runtimeEnvironmentKeys"]:
|
||||
evidence["intent"] = {
|
||||
**dict(evidence.get("intent") or {}),
|
||||
"runtime": {"applicationId": plan.application_id, "keys": list(runtime_values)},
|
||||
}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="sealing_runtime", evidence=evidence
|
||||
)
|
||||
runtime_result = self.broker.configure_enrollment_runtime(
|
||||
{
|
||||
"enrollment_id": enrollment.id,
|
||||
"application_id": plan.application_id,
|
||||
"repository": plan.repository,
|
||||
"values": runtime_values,
|
||||
},
|
||||
secret=target_secret,
|
||||
)
|
||||
evidence["runtime"] = {key: runtime_result[key] for key in ("status", "enrollmentId", "applicationId") if key in runtime_result}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="sealing_runtime", evidence=evidence
|
||||
)
|
||||
if runtime_result.get("status") not in {"configured", "already_configured"}:
|
||||
return self._fail(session, enrollment, job, stage="sealing_runtime", message="Controller could not seal profile-owned runtime values.", evidence=evidence)
|
||||
|
||||
evidence["intent"] = {
|
||||
**dict(evidence.get("intent") or {}),
|
||||
"policies": [
|
||||
f"enrollment-{plan.application_id}-deployment-apply",
|
||||
f"enrollment-{plan.application_id}-deployment-rollback",
|
||||
],
|
||||
}
|
||||
self._checkpoint(
|
||||
session, enrollment, job, stage="installing_policy", evidence=evidence
|
||||
)
|
||||
try:
|
||||
policy_ids = self._install_policies(session, enrollment, plan)
|
||||
except EnrollmentError as exc:
|
||||
return self._fail(session, enrollment, job, stage="installing_policy", message=str(exc), evidence=evidence)
|
||||
evidence["policies"] = policy_ids
|
||||
enrollment.status = "ready"
|
||||
enrollment.resources_json = {
|
||||
"appdataPath": plan.appdata_path,
|
||||
"dataPath": plan.data_path,
|
||||
"network": plan.network.docker_network,
|
||||
"hostname": plan.exposure.hostname if plan.exposure else None,
|
||||
**evidence,
|
||||
}
|
||||
enrollment.last_verified_at = utc_now()
|
||||
enrollment.updated_at = utc_now()
|
||||
enrollment.completed_at = utc_now()
|
||||
job.status = "ready"
|
||||
job.stage = "ready"
|
||||
job.evidence_json = evidence
|
||||
job.result_summary = "Bounded target, profile resources and exact policies are ready for deployment."
|
||||
job.updated_at = utc_now()
|
||||
job.completed_at = utc_now()
|
||||
session.add(enrollment)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
log_event(
|
||||
session,
|
||||
source="enrollment-plane",
|
||||
identity="operator",
|
||||
action_id="target.enroll",
|
||||
target=plan.application_id,
|
||||
target_type="enrollment",
|
||||
risk="high",
|
||||
policy_decision="allowed",
|
||||
reason="Bounded application identity was enrolled from an autonomous profile.",
|
||||
result="success",
|
||||
execution_plan={"enrollmentId": enrollment.id, "planSha256": plan_digest, "profile": plan.profile},
|
||||
after_state={"status": "ready", "policyIds": policy_ids},
|
||||
)
|
||||
return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)}
|
||||
|
||||
def list_enrollments(self, session: Session, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
rows = session.exec(select(Enrollment).order_by(Enrollment.created_at.desc()).limit(max(1, min(limit, 250)))).all()
|
||||
return [self.serialize_enrollment(row) for row in rows]
|
||||
|
||||
def get_enrollment(self, session: Session, enrollment_id: str) -> dict[str, Any]:
|
||||
row = session.get(Enrollment, enrollment_id)
|
||||
if row is None:
|
||||
raise EnrollmentError("enrollment was not found")
|
||||
jobs = session.exec(
|
||||
select(EnrollmentJob).where(EnrollmentJob.enrollment_id == enrollment_id).order_by(EnrollmentJob.created_at.desc()).limit(20)
|
||||
).all()
|
||||
return {"enrollment": self.serialize_enrollment(row), "jobs": [self.serialize_job(job) for job in jobs]}
|
||||
Reference in new issue
Block a user