diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 0ca816d57..0d8a34b42 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -2188,13 +2188,14 @@ jobs: # ─── Step 1: Apply NetworkPolicy + ConfigMap + ServiceRegistry ─── # NetworkPolicy is intentionally outside kustomize because commonLabels - # mutates nested selectors. Keep a live rollback artifact until the - # live socket and public receipt verifiers have both passed. - # The runner checkout is intentionally single-commit, so HEAD^ is not - # available here. Snapshot live policy truth before any mutation. + # mutates nested selectors. The checkout is depth=1, so snapshot live + # policy truth and remove server-only metadata before any mutation. NETWORK_POLICY_ROLLBACK_FILE="/tmp/awoooi-network-policy-rollback.json" NETWORK_POLICY_LIVE_RAW_FILE="/tmp/awoooi-network-policy-live.json" NETWORK_POLICY_CHANGE_ACTIVE="0" + BROKER_POLICY_NAME="allow-ansible-executor-broker-ssh-egress" + BROKER_POLICY_EXISTED=$(ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ + "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; if \$KUBECTL get networkpolicy '$BROKER_POLICY_NAME' -n awoooi-prod >/dev/null 2>&1; then printf 1; else printf 0; fi") ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL get networkpolicy -n awoooi-prod -o json" \ > "$NETWORK_POLICY_LIVE_RAW_FILE" @@ -2250,10 +2251,20 @@ jobs: if cat "$NETWORK_POLICY_ROLLBACK_FILE" | \ ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply -f -"; then - NETWORK_POLICY_CHANGE_ACTIVE="0" - return 0 + restore_status="0" + else + restore_status="$?" fi - return 1 + if [ "$BROKER_POLICY_EXISTED" = "0" ]; then + if ! ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ + "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL delete networkpolicy '$BROKER_POLICY_NAME' -n awoooi-prod --ignore-not-found=true"; then + restore_status="1" + fi + fi + if [ "$restore_status" = "0" ]; then + NETWORK_POLICY_CHANGE_ACTIVE="0" + fi + return "$restore_status" } rollback_network_policy_on_exit() { exit_code=$? @@ -2533,6 +2544,30 @@ jobs: # 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. + auth_can_i() { + local output + local status + local answer + set +e + output=$($KUBECTL auth can-i "$@" 2>&1) + status=$? + set -e + answer=$(printf '%s\n' "$output" | tail -1 | tr -d '\r') + case "$answer" in + yes|no) + printf '%s' "$answer" + return 0 + ;; + *) + echo "❌ kubectl auth can-i returned an invalid response: $output" >&2 + if [ "$status" -eq 0 ]; then + status=1 + fi + return "$status" + ;; + esac + } + echo "executor_boundary_stage=collect_live_identity_and_policy" 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 \ @@ -2559,25 +2594,25 @@ jobs: -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 \ + API_CAN_GET=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-api \ get pods --all-namespaces) - API_CAN_PATCH=$($KUBECTL auth can-i \ + API_CAN_PATCH=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-api \ patch deployments.apps --all-namespaces) - API_CAN_DELETE=$($KUBECTL auth can-i \ + API_CAN_DELETE=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-api \ delete pods --all-namespaces) - WORKER_CAN_PATCH=$($KUBECTL auth can-i \ + WORKER_CAN_PATCH=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-worker \ patch deployments.apps --all-namespaces) - WORKER_CAN_DELETE=$($KUBECTL auth can-i \ + WORKER_CAN_DELETE=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-worker \ delete pods --all-namespaces) - BROKER_CAN_PATCH=$($KUBECTL auth can-i \ + BROKER_CAN_PATCH=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-executor-broker \ patch deployments.apps --all-namespaces) - BROKER_CAN_DELETE=$($KUBECTL auth can-i \ + BROKER_CAN_DELETE=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-executor-broker \ delete pods --all-namespaces) COMMON_SSH_PORT_COUNT=$($KUBECTL get networkpolicy \ @@ -2687,6 +2722,7 @@ jobs: test "$LEGACY_EGRESS_RULE_COUNT" = "0" || { echo "❌ legacy namespace policy still creates an allow rule"; exit 1; } + echo "executor_boundary_stage=static_identity_and_policy_verified" verify_ssh_denied() { workload="$1" @@ -2739,6 +2775,7 @@ jobs: verify_broker_ssh_allowed || { echo "❌ execution broker cannot reach any allowlisted SSH endpoint"; exit 1; } + echo "executor_boundary_stage=live_socket_boundary_verified" BOUNDARY_VERIFIED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") $KUBECTL create configmap awoooi-executor-boundary-verification \ diff --git a/apps/api/tests/test_executor_network_policy_boundary.py b/apps/api/tests/test_executor_network_policy_boundary.py index f73afc0b3..52f16174b 100644 --- a/apps/api/tests/test_executor_network_policy_boundary.py +++ b/apps/api/tests/test_executor_network_policy_boundary.py @@ -73,6 +73,12 @@ def test_cd_applies_rolls_back_and_verifies_network_boundary() -> None: assert "get networkpolicy -n awoooi-prod -o json" in workflow assert "rollback_network_policy_on_exit" in workflow assert "git show HEAD^:k8s/awoooi-prod/02-network-policy.yaml" not in workflow + assert "BROKER_POLICY_EXISTED" in workflow + assert "$KUBECTL delete networkpolicy '$BROKER_POLICY_NAME'" in workflow + assert "auth_can_i()" in workflow + assert "API_CAN_PATCH=$(auth_can_i" in workflow + assert "BROKER_CAN_DELETE=$(auth_can_i" in workflow + assert "executor_boundary_stage=live_socket_boundary_verified" in workflow assert "allow-ansible-executor-broker-ssh-egress" in workflow assert "api_ssh_egress_denied=true" in workflow assert "worker_ssh_egress_denied=true" in workflow diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index c086a09f3..550a49ea8 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -79,12 +79,12 @@ spec: - name: AWOOOI_BUILD_COMMIT_SHA # 2026-06-29 Codex: CD rewrites this to the deployed image tag so # production deploy readback does not rely on a stale static snapshot. - value: "de5e29360f3d1808daf0e0d0991aa3cce233f124" + value: "954abc68642ac65f3a99b618220f413af94abda8" - name: AWOOOI_DESIRED_API_IMAGE_TAG # 2026-06-30 Codex: CD rewrites this alongside AWOOOI_BUILD_COMMIT_SHA. # Production readback compares runtime image truth against this # GitOps desired tag instead of doing a slow Gitea raw fetch. - value: "de5e29360f3d1808daf0e0d0991aa3cce233f124" + value: "954abc68642ac65f3a99b618220f413af94abda8" - name: DATABASE_POOL_SIZE # 2026-07-01 Codex: production role `awoooi` currently has a low # connection limit. Keep API pool conservative until DB role diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index 23b6822e3..1664b4dcf 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -42,7 +42,7 @@ resources: images: - name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api - newTag: de5e29360f3d1808daf0e0d0991aa3cce233f124 + newTag: 954abc68642ac65f3a99b618220f413af94abda8 - name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web - newTag: de5e29360f3d1808daf0e0d0991aa3cce233f124 + newTag: 954abc68642ac65f3a99b618220f413af94abda8