From 3b8bdcea801b921255af641c2226652da556698a Mon Sep 17 00:00:00 2001 From: ogt Date: Sat, 11 Jul 2026 00:12:04 +0800 Subject: [PATCH] fix(cd): verify API executor boundary in production --- .gitea/workflows/cd.yaml | 76 +++++++++++++++++-- ..._signal_worker_ansible_executor_binding.py | 21 +++++ k8s/awoooi-prod/06-deployment-api.yaml | 4 + 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index e1e6853dd..2ec74d0e1 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -2160,14 +2160,13 @@ jobs: echo "✅ Service Registry ConfigMap 已更新" # ─── Step 2: 更新 kustomization.yaml image tag ─── - # host runner 不保證有 root 權限,kustomize 安裝在使用者目錄。 + # kustomize is an immutable dependency of the internal runner image. + # GitHub is frozen, so CD must never download tools from github.com. export PATH="${HOME}/.local/bin:${PATH}" - if ! command -v kustomize &>/dev/null; then - mkdir -p "${HOME}/.local/bin" - curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.3.0/kustomize_v5.3.0_linux_amd64.tar.gz \ - | tar xz -C "${HOME}/.local/bin" - chmod +x "${HOME}/.local/bin/kustomize" - fi + command -v kustomize >/dev/null 2>&1 || { + echo "❌ internal runner image is missing required kustomize" + exit 1 + } cd k8s/awoooi-prod # kustomize edit set image 更新 tag @@ -2395,6 +2394,69 @@ jobs: $KUBECTL rollout status deployment/awoooi-worker -n awoooi-prod --timeout=120s echo "✅ 部署完成" + # AIA-P0-011: production must prove that the public API cannot mutate + # workloads or read host executor credentials. Source YAML alone is + # insufficient because stale SSA-owned list fields can survive sync. + API_SA=$($KUBECTL get deployment/awoooi-api -n awoooi-prod \ + -o jsonpath='{.spec.template.spec.serviceAccountName}') + WORKER_SA=$($KUBECTL get deployment/awoooi-worker -n awoooi-prod \ + -o jsonpath='{.spec.template.spec.serviceAccountName}') + API_MOUNTS=$($KUBECTL get deployment/awoooi-api -n awoooi-prod \ + -o jsonpath='{range .spec.template.spec.containers[?(@.name=="api")].volumeMounts[*]}{.name}{"\n"}{end}') + API_VOLUMES=$($KUBECTL get deployment/awoooi-api -n awoooi-prod \ + -o jsonpath='{range .spec.template.spec.volumes[*]}{.name}{"\n"}{end}') + API_FS_GROUP=$($KUBECTL get deployment/awoooi-api -n awoooi-prod \ + -o jsonpath='{.spec.template.spec.securityContext.fsGroup}') + API_SSH_MCP=$($KUBECTL get deployment/awoooi-api -n awoooi-prod \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="api")].env[?(@.name=="SSH_MCP_ENABLED")].value}') + API_CHECK_WORKER=$($KUBECTL get deployment/awoooi-api -n awoooi-prod \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="api")].env[?(@.name=="ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER")].value}') + API_BACKFILL_WORKER=$($KUBECTL get deployment/awoooi-api -n awoooi-prod \ + -o jsonpath='{.spec.template.spec.containers[?(@.name=="api")].env[?(@.name=="ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER")].value}') + API_CAN_GET=$($KUBECTL auth can-i \ + --as=system:serviceaccount:awoooi-prod:awoooi-api \ + get pods --all-namespaces) + API_CAN_PATCH=$($KUBECTL auth can-i \ + --as=system:serviceaccount:awoooi-prod:awoooi-api \ + patch deployments.apps --all-namespaces) + API_CAN_DELETE=$($KUBECTL auth can-i \ + --as=system:serviceaccount:awoooi-prod:awoooi-api \ + delete pods --all-namespaces) + + test "$API_SA" = "awoooi-api" || { + echo "❌ API ServiceAccount drift: $API_SA"; exit 1; + } + test "$WORKER_SA" = "awoooi-executor" || { + echo "❌ worker ServiceAccount drift: $WORKER_SA"; exit 1; + } + if printf '%s\n%s\n' "$API_MOUNTS" "$API_VOLUMES" \ + | grep -Eq '^(repair-ssh-key|repair-known-hosts|ssh-mcp-key)$'; then + echo "❌ API still mounts executor SSH credentials" + exit 1 + fi + test -z "$API_FS_GROUP" || { + echo "❌ API still carries executor fsGroup: $API_FS_GROUP"; exit 1; + } + test "$API_SSH_MCP" = "false" || { + echo "❌ API SSH MCP is not disabled"; exit 1; + } + test "$API_CHECK_WORKER" = "false" || { + echo "❌ API check-mode executor loop is not disabled"; exit 1; + } + test "$API_BACKFILL_WORKER" = "false" || { + echo "❌ API candidate backfill executor loop is not disabled"; exit 1; + } + test "$API_CAN_GET" = "yes" || { + echo "❌ API read capability missing"; exit 1; + } + test "$API_CAN_PATCH" = "no" || { + echo "❌ API can still patch workloads"; exit 1; + } + test "$API_CAN_DELETE" = "no" || { + echo "❌ API can still delete pods"; exit 1; + } + echo "✅ AIA-P0-011 API/executor production boundary verified" + # Health Check HEALTH_PASS=0 for i in 1 2 3; do diff --git a/apps/api/tests/test_signal_worker_ansible_executor_binding.py b/apps/api/tests/test_signal_worker_ansible_executor_binding.py index c2c4212b9..6f05e2fdb 100644 --- a/apps/api/tests/test_signal_worker_ansible_executor_binding.py +++ b/apps/api/tests/test_signal_worker_ansible_executor_binding.py @@ -96,6 +96,27 @@ def test_api_reader_rbac_has_no_workload_write_verbs() -> None: ) +def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None: + repo_root = Path(__file__).resolve().parents[3] + deployment = next( + document + for document in yaml.safe_load_all( + (repo_root / "k8s/awoooi-prod/06-deployment-api.yaml").read_text() + ) + if document and document.get("kind") == "Deployment" + ) + workflow = (repo_root / ".gitea/workflows/cd.yaml").read_text() + + assert deployment["metadata"]["annotations"][ + "argocd.argoproj.io/sync-options" + ] == "Replace=true" + assert "AIA-P0-011 API/executor production boundary verified" in workflow + assert "patch deployments.apps --all-namespaces" in workflow + assert "delete pods --all-namespaces" in workflow + assert "repair-ssh-key|repair-known-hosts|ssh-mcp-key" in workflow + assert "github.com/kubernetes-sigs/kustomize" not in workflow + + def test_api_log_does_not_claim_skipped_executor_was_scheduled() -> None: repo_root = Path(__file__).resolve().parents[3] main_source = (repo_root / "apps/api/src/main.py").read_text() diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index beb58fbec..521c37f2e 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -9,6 +9,10 @@ kind: Deployment metadata: name: awoooi-api namespace: awoooi-prod + annotations: + # One controlled replace prunes legacy SSH mounts that were not owned by + # Argo CD's server-side apply field manager. Remove after prod verification. + argocd.argoproj.io/sync-options: Replace=true labels: app: awoooi-api system: awoooi