# ============================================================================= # AWOOOI CD Pipeline (Gitea Actions - 方案 B) # ============================================================================= # 流程: Build → Push to Harbor → Deploy to K8s # 加速措施: # 1. Docker Layer Cache → Harbor registry cache # 2. 內部 Mirror → 192.168.0.110:5001 (Harbor Proxy Cache for DockerHub) # 3. 非 110 runner 的 Docker pull/push 走 registry.wooo.work HTTPS alias, # 避免要求 runner root 修改 insecure registry;K8s image pull 仍保留 # 192.168.0.110:5000 內網 Harbor route。 # 2026-03-29 Claude Code (ADR-039) - Retry after creating Harbor project name: CD Pipeline on: # 2026-06-29 Codex: restore main push CD only after the non-110 # awoooi-non110-* runner lane read back registration metadata, active # service, capacity=1, pressure OK, rollback unit, and label target-match. # 110 incident runner labels and generic labels remain fail-closed via # ops/runner/guard-gitea-runner-pressure.py. push: branches: - main workflow_dispatch: # 手動觸發永遠可用(用於補跑、緊急部署) # Main deployments run serially so concurrent writers cannot starve production closure. # Job timeouts, rollout verification, and deploy markers terminate or expose failed runs. concurrency: group: cd-deploy-${{ github.ref }} cancel-in-progress: false env: HARBOR: registry.wooo.work # Harbor Proxy Cache (指向 DockerHub 的內部 Mirror,避免拉取限額) HARBOR_MIRROR: 192.168.0.110:5001 # OTEL CI/CD 監控 (2026-03-31 #46c - 遷移到 Gitea) OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.0.188:24318 OTEL_SERVICE_NAME: awoooi-cd OTEL_RESOURCE_ATTRIBUTES: service.version=${{ github.sha }},deployment.environment=production CI_IMAGE: registry.wooo.work/awoooi/ci-runner:act-22.04 # 2026-06-28 Codex: 110 runner pressure is an incident-grade capacity guard. # Do not flip this to warn-only until non-110 readiness is verified. HOST_WEB_BUILD_PRESSURE_WARN_ONLY: "0" # 2026-06-30 Codex: CD is now pinned to the dedicated awoooi-non110-host lane. # Keep the pressure guard fail-hard, but allow normal below-saturation load on # that dedicated lane so P0 deploys are not stuck behind load5/core 0.85-1.05. HOST_WEB_BUILD_PRESSURE_MAX_LOAD5_PER_CORE: "1.05" # Docker lock contention is also fail-hard during the same incident window. DOCKER_BUILD_LOCK_WARN_ONLY: "0" # 2026-05-24 Codex: deploy through the currently Ready control-plane node. # 120 is NotReady/SchedulingDisabled and its SSH/API endpoints are currently # unreachable; pinning CD to it blocks secret injection before GitOps deploy. K8S_SSH_HOST: 192.168.0.121 K8S_API_SERVER: https://192.168.0.121:6443 # 2026-06-01 Codex: post-deploy health/smoke probes use the production # public API. The old 192.168.0.125 NodePort VIP can be absent while the # public route and in-cluster service are healthy, causing false failures. API_HEALTH_URL: https://awoooi.wooo.work/api/v1/health ALERT_CHAIN_API_URL: https://awoooi.wooo.work jobs: workflow-shape: # 2026-06-28 Codex: Gitea 1.25 may mark a workflow invalid when every # root job has a job-level `if`. Keep one no-op root job without `if` so # cd.yaml stays parseable while deploy jobs remain guarded below. runs-on: awoooi-non110-host timeout-minutes: 1 steps: - name: Confirm CD Workflow Shape run: echo "cd.yaml root job present; deploy jobs remain guarded." cancel-stale-cd: # 2026-06-28 Codex: keep a visible no-op run for controlled queue # cancellation. If every job is skipped, Gitea may not create a run and # the stale pre-guard CD queue is not superseded by concurrency. if: ${{ github.event_name == 'push' && contains(github.event.head_commit.message, 'cancel-stale-cd') }} runs-on: awoooi-non110-host timeout-minutes: 3 steps: - name: Confirm Stale CD Queue Cancellation run: | echo "cancel-stale-cd marker accepted; deploy jobs are intentionally skipped." tests: # 2026-06-28 Codex: Gitea does not consistently short-circuit `[skip ci]` # on CD-generated deploy commits. Skip jobs explicitly so marker commits # do not trigger a self-feeding CD loop; `cancel-stale-cd` is a # controlled no-op trigger used only to cancel stale pre-guard runs. if: ${{ github.event_name != 'push' || (!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, 'cancel-stale-cd')) }} # 2026-04-30 Codex: run the tests job on the host runner and launch the # CI image explicitly. The act-managed job container can disappear mid-test # with Docker RWLayer=nil on the shared 110 daemon. timeout-minutes: 30 runs-on: awoooi-non110-host # 2026-04-10 ogt: B5 改用 docker run 本地啟動,移除 services: 宣告 # Gitea act runner 的 services: container name 為空,導致 CI 失敗 steps: - name: Bootstrap Host Runner Tools # 2026-06-28 Codex: awoooi-non110-host maps to the dedicated # non-110 runner lane. Bootstrap tools defensively because host # runners can start without the CI toolchain preinstalled. run: | if command -v apk >/dev/null 2>&1; then apk add --no-cache nodejs npm git curl bash coreutils python3 openssh-client docker-cli docker-cli-buildx fi - name: Checkout source from Gitea env: GITEA_SOURCE_URL: http://192.168.0.110:3001/wooo/awoooi.git run: | set -euo pipefail git init . git remote remove origin 2>/dev/null || true git remote add origin "$GITEA_SOURCE_URL" git fetch --no-tags --prune --depth=1 origin "$GITHUB_SHA" git checkout --force --detach FETCH_HEAD - name: Wait for Host Web Build Pressure # 2026-06-28 Codex: 110 runner pressure remains incident-grade and # fail-hard until runner work is moved or hard-limited. run: bash scripts/ci/wait-host-web-build-pressure.sh - name: Guard Workflow Secret Surfaces run: node scripts/ci/check-gitea-step-env-secrets.js # 2026-03-31 ogt: 優化告警格式 - 提高可讀性 - name: Get Commit Info id: commit run: | echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT echo "message=$(git log -1 --pretty=%s | head -c 50)" >> $GITHUB_OUTPUT echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT - name: Notify Pipeline Start # 2026-04-16 ogt + Claude Sonnet 4.6: 改用 HTML 結構化格式,提升可讀性 env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} run: | COMMIT_MSG="${{ steps.commit.outputs.message }}" SHORT_SHA="${{ steps.commit.outputs.short_sha }}" ACTOR="${{ github.actor }}" # HTML escape commit message(防特殊字元破壞 HTML) COMMIT_ESC=$(echo "$COMMIT_MSG" | sed 's/&/\&/g; s//\>/g') MSG=$(printf '🚀 AWOOOI 部署開始\n├ 📝 %s\n├ 🔖 %s\n└ 👤 %s' "${COMMIT_ESC}" "${SHORT_SHA}" "${ACTOR}") # 2026-05-02 Claude Opus 4.7 + 統帥 ogt: notify 失敗不該擋整條 CI(鐵證: # curl 400 從 5/1 起連續炸 14 個 commit 的 build-and-deploy)— 對齊 line 922 既有 pattern if AWOOI_CICD_STATUS=running \ AWOOI_CICD_STAGE=tests \ AWOOI_CICD_JOB_NAME="AWOOOI 部署開始" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_SUMMARY="${COMMIT_MSG}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD start notification mirrored through AWOOI API" else echo "AWOOI API notify failed; direct Telegram fallback disabled to preserve AwoooP receipt chain" fi # 2026-03-31 ogt: Phase 22.0 CI 測試 (禁止 Mock - feedback_no_mock_testing.md) # 2026-04-01 ogt: 持久化 venv 加速 - /opt/api-venv 跨 run 保留 # pyproject.toml hash 變才重裝,其餘直接 activate (節省 ~6-7 min) - name: Run API Tests run: | CHANGED_FILES="" if [ -r "${GITHUB_EVENT_PATH:-}" ]; then CHANGED_FILES="$(python3 - <<'PY' import json import os event_path = os.environ.get("GITHUB_EVENT_PATH") files = [] with open(event_path, "r", encoding="utf-8") as handle: payload = json.load(handle) for commit in payload.get("commits", []) or []: for key in ("added", "modified", "removed"): files.extend(commit.get(key, []) or []) for path in dict.fromkeys(files): print(path) PY )" fi if [ -z "$CHANGED_FILES" ]; then BASE_SHA="${{ github.event.before }}" if [ -n "$BASE_SHA" ] && ! printf '%s' "$BASE_SHA" | grep -Eq '^0+$'; then git fetch --no-tags --depth=50 origin "${GITHUB_REF_NAME:-main}" >/dev/null 2>&1 || true if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "${GITHUB_SHA:-HEAD}")" fi fi fi if [ -z "$CHANGED_FILES" ]; then CHANGED_FILES="$(git show --format= --name-only --no-renames HEAD)" fi printf 'CD changed files:\n%s\n' "$CHANGED_FILES" CONTROLLED_RUNTIME_TEST_PROFILE=1 while IFS= read -r changed_file; do [ -z "$changed_file" ] && continue case "$changed_file" in # 2026-06-29 Codex: UI-only changes are verified by the # frontend build in build-and-deploy. Keep them on the narrow # profile so non-110 CD does not run B5's Docker/socket DB # integration for copy/layout fixes. apps/web/*) ;; .gitea/workflows/cd.yaml) ;; # 2026-06-30 Codex: workflow secret-transport and guard-only # hardening must stay on the narrow profile. These changes are # validated by workflow-shape, the secret-surface guard, and the # runner pressure/profile tests; sending them to full/B5 would # reintroduce the heavy runner path while not increasing coverage. .gitea/workflows/cd-dev.yaml) ;; .gitea/workflows/code-review.yaml) ;; .gitea/workflows/deploy-alerts.yaml) ;; .gitea/workflows/e2e-health.yaml) ;; .gitea/workflows/ansible-lint.yml) ;; .gitea/workflows/harbor-110-local-repair.yaml) ;; .gitea/workflows/run-migration.yml) ;; scripts/ci/check-gitea-step-env-secrets.js) ;; # 2026-06-29 Codex: the onboarding warning-step workflow is # copied in a disabled workflow_dispatch-only state. Treat the # source and template files as controlled-runtime sources so the # CD lane does not fall into full/B5 just for placing the inert # guarded workflow shell. .gitea/workflows/awoooi-onboarding-warning-step.yaml) ;; docs/operations/templates/awoooi-gitea-onboarding-warning-step.workflow.yaml) ;; # 2026-06-29 Codex: build-and-deploy writes only these GitOps # deploy marker files after image push. A later merge commit can # carry them back through CD; keep that marker merge on the # controlled profile so non-110 CD does not fall into B5's Docker # socket path just because the previous deploy recorded image # truth. k8s/awoooi-prod/04-configmap.yaml) ;; k8s/awoooi-prod/06-deployment-api.yaml) ;; k8s/awoooi-prod/07-rbac.yaml) ;; k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml) ;; k8s/awoooi-prod/08-deployment-worker.yaml) ;; k8s/awoooi-prod/10-deployment-auto-repair-canary.yaml) ;; k8s/awoooi-prod/kustomization.yaml) ;; # 2026-07-02 Codex: public maintenance fallback Nginx # source and static assets are verified by source contracts, # shell syntax, and focused reboot tests. Keep them off B5's # Docker/socket DB path so edge 502 hardening is not blocked by # an unrelated full-profile integration lane. k8s/nginx/awoooi-prod.conf) ;; ops/maintenance/maintenance.html) ;; ops/maintenance/nginx-502-maintenance-snippet.conf) ;; ops/nginx/awoooi.wooo.work.conf) ;; scripts/reboot-recovery/public-maintenance-edge-fallback-apply.sh) ;; scripts/reboot-recovery/public-maintenance-fallback-probe.py) ;; product.awoooi.yaml) ;; docs/LOGBOOK.md) ;; docs/runbooks/REBOOT-RECOVERY-SOP.md) ;; docs/runbooks/REBOOT-POST-START-QUICK-CHECK.md) ;; docs/runbooks/FULL-STACK-COLD-START-SOP.md) ;; docs/runbooks/HOST-RUNAWAY-PROCESS-AIOPS-PLAYBOOK.md) ;; docs/runbooks/PUBLIC-MAINTENANCE-FALLBACK-RUNBOOK.md) ;; docs/awooop/TELEGRAM-INCIDENT-NOTIFICATION-MODEL.md) ;; docs/security/TELEGRAM-NOTIFICATION-EGRESS-INVENTORY.md) ;; docs/security/TELEGRAM-NOTIFICATION-EGRESS-MIGRATION-PLAN-DRAFT.md) ;; docs/security/TELEGRAM-NOTIFICATION-EGRESS-NO-NEW-BYPASS-GUARD.md) ;; docs/security/TELEGRAM-NOTIFICATION-EGRESS-OWNER-REQUEST-DRAFT.md) ;; docs/security/TELEGRAM-NOTIFICATION-EGRESS-OWNER-RESPONSE-ACCEPTANCE.md) ;; docs/security/telegram-notification-egress-inventory.snapshot.json) ;; docs/security/telegram-notification-egress-owner-request-draft.snapshot.json) ;; docs/security/telegram-notification-egress-migration-plan-draft.snapshot.json) ;; docs/security/telegram-notification-egress-no-new-bypass-guard.snapshot.json) ;; docs/security/telegram-notification-egress-owner-response-acceptance.snapshot.json) ;; docs/workplans/2026-06-04-reboot-cold-start-backup-recovery-workplan.md) ;; docs/workplans/2026-07-02-commander-inserted-requirements-priority-ledger.md) ;; docs/schemas/product_awoooi_manifest_v1.schema.json) ;; docs/operations/product-awoooi-manifest-standard.snapshot.json) ;; docs/operations/awoooi-priority-work-order-readback.snapshot.json) ;; docs/operations/awooop-conversation-event-hot-path-index-apply-receipt-*.snapshot.json) ;; docs/operations/awoooi-credential-escrow-evidence-controlled-closeout-receipt.snapshot.json) ;; docs/operations/awoooi-reboot-auto-recovery-slo-scorecard.snapshot.json) ;; docs/operations/awoooi-gitea-private-inventory-p0-scorecard.snapshot.json) ;; docs/operations/awoooi-gitea-private-inventory-controlled-closeout-receipt.snapshot.json) ;; docs/operations/awoooi-gitea-authenticated-inventory-payload-validation.snapshot.json) ;; docs/security/GITEA-REPO-INVENTORY-SNAPSHOT.md) ;; docs/security/gitea-repo-inventory.snapshot.json) ;; docs/operations/p0-cicd-baseline-source-readiness.snapshot.json) ;; docs/operations/awoooi-gitea-onboarding-warning-step-template-copy-receipt.snapshot.json) ;; .gitea/workflows/awoooi-onboarding-warning-step.yaml) ;; docs/operations/templates/awoooi-gitea-onboarding-warning-step.workflow.yaml) ;; docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json) ;; docs/evaluations/backup_dr_target_inventory_2026-06-04.json) ;; docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json) ;; docs/operations/ai-agent-log-intelligence-runtime-sample-readback.snapshot.json) ;; apps/api/src/api/v1/agents.py) ;; apps/api/src/api/v1/iwooos.py) ;; apps/api/src/api/v1/webhooks.py) ;; apps/api/tests/test_alertmanager_webhook_metrics.py) ;; apps/api/src/core/config.py) ;; apps/api/src/db/base.py) ;; apps/api/src/services/agent_replay_normalizer.py) ;; apps/api/src/services/ai_agent_log_intelligence_integration_readback.py) ;; apps/api/src/services/ai_agent_log_feedback_receipt_dry_run.py) ;; apps/api/src/services/ai_agent_log_post_write_verifier_dry_run.py) ;; apps/api/src/services/ai_agent_log_controlled_writeback_plan_readback.py) ;; apps/api/src/services/ai_agent_log_controlled_writeback_executor_readback.py) ;; apps/api/src/services/ai_agent_log_controlled_writeback_dispatch.py) ;; apps/api/src/services/ai_agent_log_controlled_writeback_consumer_readback.py) ;; apps/api/src/services/ai_agent_log_controlled_writeback_consumer_apply.py) ;; apps/api/src/services/ai_agent_autonomous_runtime_control.py) ;; apps/api/src/services/ai_agent_report_truth_actionability_review.py) ;; apps/api/src/services/awooop_ansible_audit_service.py) ;; apps/api/src/services/awooop_ansible_check_mode_service.py) ;; apps/api/src/services/executor_trust_boundary_readback.py) ;; apps/api/src/workers/ansible_executor_broker.py) ;; apps/api/src/workers/signal_worker.py) ;; apps/api/migrations/adr090e_ansible_learning_writeback_operation_type.sql) ;; apps/api/migrations/adr090e_ansible_learning_writeback_operation_type_down.sql) ;; apps/api/migrations/adr090f_log_controlled_writeback_dispatch_operation_type.sql) ;; apps/api/migrations/adr090f_log_controlled_writeback_dispatch_operation_type_down.sql) ;; apps/api/migrations/asset_inventory_database_types_2026-07-14.sql) ;; apps/api/migrations/asset_inventory_database_types_2026-07-14_down.sql) ;; apps/api/src/services/auto_approve.py) ;; apps/api/src/services/decision_fusion.py) ;; apps/api/src/services/heartbeat_report_service.py) ;; apps/api/src/services/credential_escrow_evidence_intake_readiness.py) ;; apps/api/src/services/gitea_authenticated_inventory_payload_validation.py) ;; apps/api/src/services/gitea_owner_coverage_attestation_validation.py) ;; apps/api/src/services/gitea_private_inventory_closeout_validation.py) ;; apps/api/src/services/gitea_private_inventory_p0_scorecard.py) ;; apps/api/src/services/gitea_workflow_runner_owner_attestation_request.py) ;; apps/api/src/services/gitea_cicd_alert_receipt_chain_readback.py) ;; apps/api/src/services/gitea_capability_ci_cd_optimization_readback.py) ;; apps/api/src/services/reboot_auto_recovery_slo_scorecard.py) ;; apps/api/src/services/windows99_vmx_source_locator_readback.py) ;; apps/api/src/services/reboot_auto_recovery_drill_preflight.py) ;; apps/api/src/services/stockplatform_public_api_runtime_readback.py) ;; apps/api/src/services/stockplatform_public_api_controlled_recovery_preflight.py) ;; apps/api/src/services/harbor_registry_controlled_recovery_preflight.py) ;; apps/api/src/services/harbor_registry_controlled_recovery_receipt.py) ;; apps/api/src/services/iwooos_security_operating_system.py) ;; apps/api/Dockerfile) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_dashboard.py) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_owner_package.py) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_owner_response_preflight.py) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_template_copy_apply_gate.py) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_template_copy_execution_plan.py) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_template_copy_receipt.py) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_runtime_enablement_gate.py) ;; apps/api/src/services/awoooi_new_product_onboarding_page_model.py) ;; apps/api/src/services/awoooi_onboarding_reminder_contract.py) ;; apps/api/src/services/awoooi_onboarding_source_contracts.py) ;; apps/api/src/services/awoooi_priority_work_order_readback.py) ;; apps/api/src/services/awoooi_product_onboarding_guard.py) ;; apps/api/src/services/p0_cicd_baseline_source_readiness.py) ;; apps/api/src/services/product_awoooi_manifest_standard.py) ;; apps/api/src/api/v1/platform/events.py) ;; apps/api/src/api/v1/platform/operator_runs.py) ;; apps/api/src/jobs/ai_slo_watchdog_job.py) ;; apps/api/src/repositories/knowledge_repository.py) ;; apps/api/src/services/knowledge_service.py) ;; apps/api/src/models/knowledge.py) ;; apps/api/src/models/playbook.py) ;; apps/api/src/services/auto_repair_service.py) ;; apps/api/src/services/awoooi_production_deploy_readback_blocker.py) ;; apps/api/src/services/backup_dr_target_inventory.py) ;; apps/api/src/services/backup_dr_readiness_matrix.py) ;; apps/api/src/services/gitea_repo_bundle_backup_readback.py) ;; apps/api/src/services/channel_hub.py) ;; apps/api/src/services/decision_manager.py) ;; apps/api/src/services/delivery_closure_workbench.py) ;; apps/api/src/services/platform_operator_service.py) ;; apps/api/src/services/telegram_alert_ai_automation_matrix.py) ;; apps/api/src/services/telegram_alert_monitoring_coverage_readback.py) ;; apps/api/src/services/telegram_gateway.py) ;; apps/api/tests/test_agent_replay_normalizer.py) ;; apps/api/tests/test_ai_agent_log_intelligence_integration_readback_api.py) ;; apps/api/tests/test_ai_agent_log_feedback_receipt_dry_run_api.py) ;; apps/api/tests/test_ai_agent_log_post_write_verifier_dry_run_api.py) ;; apps/api/tests/test_ai_agent_log_controlled_writeback_plan_readback_api.py) ;; apps/api/tests/test_ai_agent_log_controlled_writeback_executor_readback_api.py) ;; apps/api/tests/test_ai_agent_log_controlled_writeback_dispatch_api.py) ;; apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py) ;; apps/api/tests/test_ai_agent_log_controlled_writeback_consumer_apply_api.py) ;; apps/api/tests/test_ai_agent_autonomous_runtime_control.py) ;; apps/api/tests/test_ai_agent_report_truth_actionability_review.py) ;; apps/api/tests/test_ai_agent_report_truth_actionability_review_api.py) ;; apps/api/tests/test_awooop_truth_chain_service.py) ;; apps/api/tests/test_executor_trust_boundary_readback.py) ;; apps/api/tests/test_signal_worker_ansible_executor_binding.py) ;; apps/api/tests/test_channel_hub_grouped_alert_events.py) ;; apps/api/tests/test_shadow_auto_approve.py) ;; apps/api/tests/test_destructive_patterns.py) ;; apps/api/tests/test_approval_pending_visibility.py) ;; apps/api/tests/test_awooop_operator_timeline_labels.py) ;; apps/api/tests/test_config_url_validation.py) ;; apps/api/tests/test_delivery_closure_workbench_api.py) ;; apps/api/tests/test_runtime_bootstrap_guards.py) ;; apps/api/tests/test_backup_dr_target_inventory.py) ;; apps/api/tests/test_backup_dr_target_inventory_api.py) ;; apps/api/tests/test_backup_dr_readiness_matrix.py) ;; apps/api/tests/test_backup_dr_readiness_matrix_api.py) ;; apps/api/tests/test_gitea_repo_bundle_backup_readback_api.py) ;; apps/api/tests/test_gitea_cicd_alert_receipt_chain_readback_api.py) ;; apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py) ;; apps/api/tests/test_knowledge_repository_read_model.py) ;; apps/api/tests/test_credential_escrow_evidence_intake_readiness_api.py) ;; apps/api/tests/test_gitea_private_inventory_p0_scorecard_api.py) ;; apps/api/tests/test_gitea_workflow_runner_owner_attestation_request_api.py) ;; apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py) ;; apps/api/tests/test_windows99_vmx_source_locator_readback_api.py) ;; apps/api/tests/test_stockplatform_public_api_runtime_readback.py) ;; apps/api/tests/test_stockplatform_public_api_controlled_recovery_preflight.py) ;; apps/api/tests/test_harbor_registry_controlled_recovery_preflight.py) ;; apps/api/tests/test_harbor_registry_controlled_recovery_receipt.py) ;; apps/api/tests/test_iwooos_security_operating_system.py) ;; apps/api/tests/test_iwooos_wazuh_prod_manifest.py) ;; apps/api/tests/test_awoooi_production_deploy_readback_blocker.py) ;; apps/api/tests/test_awoooi_priority_work_order_readback_api.py) ;; apps/api/tests/test_telegram_alert_ai_automation_matrix_api.py) ;; apps/api/tests/test_telegram_alert_monitoring_coverage_readback_api.py) ;; apps/api/tests/e2e_network_test.py) ;; apps/api/tests/test_p0_cicd_baseline_source_readiness_api.py) ;; apps/api/tests/test_product_awoooi_manifest_standard_api.py) ;; apps/api/tests/test_trust_drift_watchdog.py) ;; apps/web/src/app/\[locale\]/governance/tabs/events-tab.tsx) ;; apps/web/src/app/\[locale\]/governance/tabs/queue-tab.tsx) ;; apps/web/src/app/\[locale\]/governance/tabs/slo-tab.tsx) ;; ops/runner/read-public-gitea-actions-queue.py) ;; ops/runner/README.md) ;; ops/runner/check-awoooi-non110-runner-readiness.sh) ;; ops/runner/install-awoooi-non110-runner-user-service.sh) ;; ops/runner/register-awoooi-110-controlled-cd-lane-drain.sh) ;; ops/runner/test_read_public_gitea_actions_queue.py) ;; ops/runner/test_cd_controlled_runtime_profile.py) ;; ops/runner/test_check_awoooi_non110_runner_readiness.py) ;; ops/runner/test_install_awoooi_non110_runner_user_service.py) ;; ops/runner/test_register_awoooi_110_controlled_cd_lane_drain.py) ;; ops/runner/test_check_awoooi_110_controlled_cd_lane_readiness.py) ;; ops/runner/test_verify_awoooi_non110_cd_closure.py) ;; ops/runner/awoooi-cd-lane-drain.service) ;; ops/runner/check-awoooi-110-controlled-cd-lane-readiness.sh) ;; ops/runner/verify-awoooi-non110-cd-closure.py) ;; docs/operations/host-cpu-pressure-drain-readback-2026-07-01.snapshot.json) ;; docs/operations/post-reboot-runtime-recovery-readback-2026-07-01.snapshot.json) ;; ops/reboot-recovery/full-stack-cold-start-baseline.yml) ;; ops/monitoring/alerts-unified.yml) ;; ops/monitoring/alerts.yml) ;; ops/alertmanager/alertmanager.yml) ;; ops/reboot-recovery/full-stack-backup-baseline.yml) ;; scripts/dev/awoooi-navigation-coverage-guard.py) ;; scripts/ci/wait-host-web-build-pressure.sh) ;; # 2026-07-01 Codex: backup freshness/readback scripts are # covered by shell syntax checks and focused exporter tests. # Keep them off B5 so a metadata/script recovery patch does not # require the host Docker socket. scripts/backup/backup-awoooi-frequent.sh) ;; scripts/backup/backup-status.sh) ;; scripts/backup/gitea-repo-bundle-backup.sh) ;; scripts/backup/gitea-bundle-sample-restore-dry-run.sh) ;; scripts/backup/tests/test_backup_status_contract.py) ;; scripts/backup/tests/test_gitea_bundle_sample_restore_dry_run.py) ;; scripts/ops/backup-alert-label-contract-check.py) ;; scripts/ops/backup-alert-live-visibility-check.py) ;; scripts/ops/backup-from-110.sh) ;; scripts/ops/backup-health-textfile-exporter.py) ;; scripts/ops/docker-disk-pressure-retention-cleanup.py) ;; scripts/ops/docker-health-monitor.sh) ;; scripts/ops/dr-drill.sh) ;; scripts/ops/gitea-queue-hook-backlog-playbook.py) ;; scripts/ops/host-runaway-process-exporter.py) ;; scripts/ops/host-sustained-load-controller.py) ;; scripts/ops/host-sustained-load-evidence.py) ;; scripts/ops/deploy-alertmanager-config.sh) ;; scripts/ops/notify-awoooi-ops.sh) ;; scripts/ops/pg-backup.sh) ;; scripts/ops/tests/test_backup_health_textfile_exporter.py) ;; scripts/ops/tests/test_docker_disk_pressure_retention_cleanup.py) ;; scripts/ops/tests/test_gitea_queue_hook_backlog_playbook.py) ;; scripts/ops/tests/test_host_runaway_process_exporter.py) ;; scripts/ops/tests/test_host_pressure_alert_contract.py) ;; scripts/ops/tests/test_alertmanager_webhook_config.py) ;; scripts/reboot-recovery/deploy-to-110.sh) ;; scripts/reboot-recovery/deploy-to-188.sh) ;; scripts/reboot-recovery/enforce-110-runner-failclosed.sh) ;; scripts/reboot-recovery/recover-110-control-path-and-harbor-local.sh) ;; scripts/reboot-recovery/apply-credential-escrow-closeout-receipt-to-110.sh) ;; scripts/reboot-recovery/awoooi-startup.sh) ;; scripts/reboot-recovery/awoooi-startup.service) ;; scripts/reboot-recovery/dr-escrow-evidence-checklist.py) ;; scripts/reboot-recovery/apply-credential-escrow-closeout-receipt-to-110.sh) ;; scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py) ;; scripts/reboot-recovery/post-reboot-owner-response-preflight.py) ;; scripts/reboot-recovery/post-start-quick-check.sh) ;; scripts/reboot-recovery/reboot-recovery-readiness-audit.sh) ;; scripts/reboot-recovery/188-host-hygiene-maintenance-checklist.sh) ;; scripts/reboot-recovery/full-stack-cold-start-check.sh) ;; scripts/reboot-recovery/cold-start-textfile-exporter.sh) ;; scripts/reboot-recovery/install-cold-start-monitor-110.sh) ;; scripts/reboot-recovery/momo-drive-token-source-recovery-preflight.sh) ;; scripts/reboot-recovery/momo-source-arrival-gate.py) ;; scripts/reboot-recovery/full-stack-recovery-scorecard.sh) ;; scripts/reboot-recovery/harbor-watchdog.sh) ;; scripts/reboot-recovery/awoooi-startup-110.sh) ;; scripts/reboot-recovery/diagnose-110-ssh-publickey-auth.sh) ;; scripts/reboot-recovery/repair-110-ssh-publickey-auth-local.sh) ;; scripts/reboot-recovery/verify-cold-start-monitor-deploy.sh) ;; scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py) ;; scripts/reboot-recovery/tests/test_cold_start_monitor_bounded_probes.py) ;; scripts/reboot-recovery/tests/test_momo_source_arrival_gate.py) ;; scripts/reboot-recovery/awoooi-reboot-auto-recovery-slo.service) ;; scripts/reboot-recovery/awoooi-reboot-auto-recovery-slo.timer) ;; scripts/reboot-recovery/install-reboot-auto-recovery-slo-110.sh) ;; scripts/reboot-recovery/reboot-auto-recovery-host-probe.sh) ;; scripts/reboot-recovery/reboot-auto-recovery-slo-exporter.sh) ;; scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py) ;; scripts/reboot-recovery/collect-windows99-vmware-verify.sh) ;; scripts/reboot-recovery/validate-windows99-console-verify-artifact.py) ;; scripts/reboot-recovery/windows99-vmware-autostart.ps1) ;; scripts/reboot-recovery/windows99-management-channel-probe.py) ;; scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_installer.py) ;; scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py) ;; scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py) ;; scripts/reboot-recovery/tests/test_windows99_console_verify_artifact_validator.py) ;; scripts/reboot-recovery/tests/test_188_host_hygiene_checklist.py) ;; scripts/reboot-recovery/tests/test_post_start_quick_check_contract.py) ;; scripts/reboot-recovery/tests/test_reboot_p0_operational_contract.py) ;; scripts/reboot-recovery/tests/test_harbor_watchdog_contract.py) ;; scripts/reboot-recovery/tests/test_recover_110_control_path_and_harbor_local.py) ;; scripts/security/gitea-private-inventory-p0-scorecard.py) ;; scripts/security/gitea-authenticated-inventory-payload-validator.py) ;; scripts/security/security-mirror-progress-guard.py) ;; scripts/security/telegram-notification-egress-no-new-bypass-guard.py) ;; scripts/security/telegram-notification-egress-owner-response-acceptance.py) ;; scripts/security/tests/test_gitea_private_inventory_p0_scorecard.py) ;; scripts/security/tests/test_gitea_authenticated_inventory_payload_validator.py) ;; *) CONTROLLED_RUNTIME_TEST_PROFILE=0 ;; esac done <> "$GITHUB_ENV" echo "✅ controlled-runtime API test profile selected" else export AWOOOI_CD_TEST_PROFILE=full echo "AWOOOI_CD_TEST_PROFILE=full" >> "$GITHUB_ENV" echo "✅ full API test profile selected" fi printf '%s\n' "$AWOOOI_CD_TEST_PROFILE" > .awoooi-cd-test-profile cat > /tmp/awoooi-api-tests.sh <<'CI_SCRIPT' VENV=/opt/api-venv HASH_FILE=/opt/api-venv/.deps_hash CURRENT_HASH=$(md5sum apps/api/pyproject.toml | awk '{print $1}') # python3.11 是 runner 層級持久安裝,只在首次或版本消失時才 apt-get # 2026-04-05 Claude Code: 分離 apt-get 與 venv hash-guard,避免每次 deps 變更都重跑 apt # 2026-04-16 ogt + Claude Sonnet 4.6: 修復 apt index 失敗 → 改用 --fix-missing + retry if ! command -v python3.11 &>/dev/null; then echo "📦 安裝 python3.11..." apt-get clean && rm -rf /var/lib/apt/lists/* apt-get update -q --fix-missing || apt-get update -q || true apt-get install -y -q python3.11-venv python3.11 || \ (add-apt-repository ppa:deadsnakes/python -y 2>/dev/null && apt-get update -q && apt-get install -y -q python3.11-venv python3.11) || true else echo "⚡ python3.11 已安裝,跳過 apt-get" fi # 確保 python3.11 存在,否則 fallback 到系統 python3 if ! command -v python3.11 &>/dev/null; then echo "⚠️ python3.11 安裝失敗,使用 python3 fallback" ln -sf "$(which python3)" /usr/local/bin/python3.11 || true fi if [ ! -d "$VENV/bin" ] || [ "$(cat $HASH_FILE 2>/dev/null)" != "$CURRENT_HASH" ]; then echo "📦 deps 已變更,重建 venv..." # 2026-04-17 ogt: /opt/api-venv 是 volume mount,不能 rm -rf 目錄本身 # 改用 find 清空內容,保留 mount point 目錄 find "$VENV" -mindepth 1 -delete 2>/dev/null || true python3.11 -m venv $VENV source $VENV/bin/activate pip install -q uv cd apps/api && uv pip install -q -e ".[dev]" && cd - echo "$CURRENT_HASH" > $HASH_FILE else echo "⚡ 使用快取 venv (deps 未變更)" source $VENV/bin/activate fi cd apps/api cleanup_pytest_workspace_cache() { # 2026-05-19 Codex: CI image runs as root against a bind-mounted # checkout. Remove Python cache artifacts before act-runner cleanup # so successful jobs do not end with root-owned __pycache__ noise. find tests src -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true rm -rf .pytest_cache 2>/dev/null || true } # CI 排除需外部服務的測試 (Redis pool / Ollama — 2026-04-01 Claude Code) # 2026-04-05 Claude Code: 修正 exit code — | tail 會吃掉 segfault (exit 139) # 改用 tee + PIPESTATUS[0] 正確捕捉 pytest 本身的 exit code # 2026-04-05 Claude Code: 加 --ignore=tests/integration 排除需 asyncpg 連線的 DB 測試 # integration tests 在 prod K8s 部署後由 E2E Smoke Test 覆蓋 # PYTHONFAULTHANDLER=1: 若 C extension segfault,輸出完整 Python stacktrace # 2026-04-05 Claude Code: test_github_webhook.py 已根治 # 原問題: import src.main → asyncpg C ext segfault (exit 139) # 修復: 改用最小化 app,只掛載 github_webhook router,不走 DB import chain # 現在可安全加入 CI 測試 # 2026-04-22 ogt: DATABASE_URL 改為必填後,單元測試需要此 env var 讓 Settings 通過驗證 # 單元測試不連 DB,此 CI placeholder 僅供 Pydantic 驗證,不產生真實連線 if [ "${AWOOOI_CD_TEST_PROFILE:-full}" = "controlled-runtime" ]; then echo "✅ controlled-runtime profile: running focused replay/auto-approve/copy tests" python3.11 ../../scripts/ci/generate-cicd-artifact-receipts.py --repo-root ../.. --out-dir /tmp/awoooi-cicd-artifacts echo "package-release-promotion source_contract=awoooi-ci-package-release-promotion.json" python3.11 -m py_compile \ src/core/config.py \ src/db/base.py \ src/api/v1/platform/events.py \ src/api/v1/agents.py \ src/api/v1/iwooos.py \ src/api/v1/webhooks.py \ src/jobs/ai_slo_watchdog_job.py \ src/repositories/knowledge_repository.py \ src/models/knowledge.py \ src/models/playbook.py \ src/services/knowledge_service.py \ src/services/awoooi_production_deploy_readback_blocker.py \ src/services/agent_replay_normalizer.py \ src/services/ai_agent_log_intelligence_integration_readback.py \ src/services/ai_agent_log_feedback_receipt_dry_run.py \ src/services/ai_agent_log_post_write_verifier_dry_run.py \ src/services/ai_agent_log_controlled_writeback_plan_readback.py \ src/services/ai_agent_log_controlled_writeback_executor_readback.py \ src/services/ai_agent_log_controlled_writeback_dispatch.py \ src/services/ai_agent_log_controlled_writeback_consumer_readback.py \ src/services/ai_agent_log_controlled_writeback_consumer_apply.py \ src/services/ai_agent_autonomous_runtime_control.py \ src/services/executor_trust_boundary_readback.py \ src/services/awooop_ansible_audit_service.py \ src/services/awooop_ansible_check_mode_service.py \ src/workers/ansible_executor_broker.py \ src/workers/signal_worker.py \ src/services/auto_repair_service.py \ src/services/auto_approve.py \ src/services/backup_dr_target_inventory.py \ src/services/backup_dr_readiness_matrix.py \ src/services/gitea_repo_bundle_backup_readback.py \ src/services/decision_fusion.py \ src/services/delivery_closure_workbench.py \ src/services/heartbeat_report_service.py \ src/services/credential_escrow_evidence_intake_readiness.py \ src/services/gitea_authenticated_inventory_payload_validation.py \ src/services/gitea_owner_coverage_attestation_validation.py \ src/services/gitea_private_inventory_closeout_validation.py \ src/services/gitea_private_inventory_p0_scorecard.py \ src/services/gitea_workflow_runner_owner_attestation_request.py \ src/services/gitea_cicd_alert_receipt_chain_readback.py \ src/services/gitea_capability_ci_cd_optimization_readback.py \ src/services/reboot_auto_recovery_slo_scorecard.py \ src/services/reboot_auto_recovery_drill_preflight.py \ src/services/stockplatform_public_api_runtime_readback.py \ src/services/stockplatform_public_api_controlled_recovery_preflight.py \ src/services/harbor_registry_controlled_recovery_preflight.py \ src/services/harbor_registry_controlled_recovery_receipt.py \ src/services/iwooos_security_operating_system.py \ src/services/awoooi_gitea_onboarding_warning_step_dashboard.py \ src/services/awoooi_gitea_onboarding_warning_step_owner_package.py \ src/services/awoooi_gitea_onboarding_warning_step_owner_response_preflight.py \ src/services/awoooi_gitea_onboarding_warning_step_template_copy_apply_gate.py \ src/services/awoooi_gitea_onboarding_warning_step_template_copy_execution_plan.py \ src/services/awoooi_gitea_onboarding_warning_step_template_copy_receipt.py \ src/services/awoooi_gitea_onboarding_warning_step_runtime_enablement_gate.py \ src/services/awoooi_new_product_onboarding_page_model.py \ src/services/awoooi_onboarding_reminder_contract.py \ src/services/awoooi_onboarding_source_contracts.py \ src/services/awoooi_priority_work_order_readback.py \ src/services/awoooi_product_onboarding_guard.py \ src/api/v1/platform/operator_runs.py \ src/services/channel_hub.py \ src/services/p0_cicd_baseline_source_readiness.py \ src/services/product_awoooi_manifest_standard.py \ src/services/platform_operator_service.py \ src/services/telegram_alert_ai_automation_matrix.py \ src/services/telegram_alert_monitoring_coverage_readback.py \ src/services/telegram_gateway.py python3.11 -m py_compile \ ../../scripts/reboot-recovery/dr-escrow-evidence-checklist.py \ ../../scripts/reboot-recovery/post-reboot-owner-response-preflight.py \ ../../scripts/reboot-recovery/momo-source-arrival-gate.py \ ../../scripts/reboot-recovery/public-maintenance-fallback-probe.py \ ../../scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py \ ../../scripts/reboot-recovery/validate-windows99-console-verify-artifact.py \ ../../scripts/reboot-recovery/windows99-management-channel-probe.py \ ../../scripts/ops/backup-alert-label-contract-check.py \ ../../scripts/ops/backup-alert-live-visibility-check.py \ ../../scripts/ops/backup-health-textfile-exporter.py \ ../../scripts/ops/docker-disk-pressure-retention-cleanup.py \ ../../scripts/ops/gitea-queue-hook-backlog-playbook.py \ ../../scripts/ops/host-runaway-process-exporter.py \ ../../scripts/ops/host-sustained-load-controller.py \ ../../scripts/ops/host-sustained-load-evidence.py \ ../../scripts/security/gitea-private-inventory-p0-scorecard.py \ ../../scripts/security/gitea-authenticated-inventory-payload-validator.py \ ../../scripts/security/runtime_image_mirror_controller.py \ ../../scripts/security/security-mirror-progress-guard.py \ ../../scripts/security/telegram-notification-egress-no-new-bypass-guard.py \ ../../scripts/security/telegram-notification-egress-owner-response-acceptance.py python3.11 -m pytest ../../scripts/ci/tests/test_generate_cicd_artifact_receipts.py -q --tb=short -p no:cacheprovider python3.11 -c "import yaml; yaml.safe_load(open('../../ops/monitoring/alerts-unified.yml')); print('alerts-unified YAML OK')" python3.11 -c "import yaml; yaml.safe_load(open('../../ops/monitoring/alerts.yml')); print('alerts YAML OK')" python3.11 -c "import yaml; yaml.safe_load(open('../../ops/alertmanager/alertmanager.yml')); print('alertmanager YAML OK')" python3.11 -c "import yaml; yaml.safe_load(open('../../ops/reboot-recovery/full-stack-cold-start-baseline.yml')); print('full-stack-cold-start-baseline YAML OK')" python3.11 -c "import yaml; yaml.safe_load(open('../../ops/reboot-recovery/full-stack-backup-baseline.yml')); print('full-stack-backup-baseline YAML OK')" bash -n \ ../../ops/runner/check-awoooi-110-controlled-cd-lane-readiness.sh \ ../../ops/runner/check-awoooi-non110-runner-readiness.sh \ ../../ops/runner/install-awoooi-non110-runner-user-service.sh \ ../../ops/runner/register-awoooi-110-controlled-cd-lane-drain.sh \ ../../scripts/reboot-recovery/deploy-to-110.sh \ ../../scripts/reboot-recovery/deploy-to-188.sh \ ../../scripts/reboot-recovery/enforce-110-runner-failclosed.sh \ ../../scripts/reboot-recovery/recover-110-control-path-and-harbor-local.sh \ ../../scripts/reboot-recovery/awoooi-startup.sh \ ../../scripts/reboot-recovery/install-reboot-auto-recovery-slo-110.sh \ ../../scripts/reboot-recovery/reboot-auto-recovery-host-probe.sh \ ../../scripts/reboot-recovery/reboot-auto-recovery-slo-exporter.sh \ ../../scripts/reboot-recovery/collect-windows99-vmware-verify.sh \ ../../scripts/reboot-recovery/post-start-quick-check.sh \ ../../scripts/reboot-recovery/188-host-hygiene-maintenance-checklist.sh \ ../../scripts/reboot-recovery/full-stack-cold-start-check.sh \ ../../scripts/reboot-recovery/cold-start-textfile-exporter.sh \ ../../scripts/reboot-recovery/install-cold-start-monitor-110.sh \ ../../scripts/reboot-recovery/momo-drive-token-source-recovery-preflight.sh \ ../../scripts/reboot-recovery/full-stack-recovery-scorecard.sh \ ../../scripts/reboot-recovery/public-maintenance-edge-fallback-apply.sh \ ../../scripts/reboot-recovery/harbor-watchdog.sh \ ../../scripts/reboot-recovery/awoooi-startup-110.sh \ ../../scripts/reboot-recovery/diagnose-110-ssh-publickey-auth.sh \ ../../scripts/reboot-recovery/repair-110-ssh-publickey-auth-local.sh \ ../../scripts/reboot-recovery/verify-cold-start-monitor-deploy.sh \ ../../scripts/reboot-recovery/apply-credential-escrow-closeout-receipt-to-110.sh \ ../../scripts/backup/backup-awoooi-frequent.sh \ ../../scripts/backup/backup-status.sh \ ../../scripts/backup/gitea-repo-bundle-backup.sh \ ../../scripts/backup/gitea-bundle-sample-restore-dry-run.sh \ ../../scripts/ops/backup-from-110.sh \ ../../scripts/ops/deploy-alertmanager-config.sh \ ../../scripts/ops/docker-health-monitor.sh \ ../../scripts/ops/dr-drill.sh \ ../../scripts/ops/notify-awoooi-ops.sh \ ../../scripts/ops/pg-backup.sh bash -n ../../scripts/reboot-recovery/apply-credential-escrow-closeout-receipt-to-110.sh DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://ci:ci@localhost/ci}" \ PYTHONFAULTHANDLER=1 python3.11 -m pytest \ tests/test_agent_replay_normalizer.py \ tests/test_ai_agent_log_intelligence_integration_readback_api.py \ tests/test_ai_agent_log_feedback_receipt_dry_run_api.py \ tests/test_ai_agent_log_post_write_verifier_dry_run_api.py \ tests/test_ai_agent_log_controlled_writeback_plan_readback_api.py \ tests/test_ai_agent_log_controlled_writeback_executor_readback_api.py \ tests/test_ai_agent_log_controlled_writeback_dispatch_api.py \ tests/test_ai_agent_log_controlled_writeback_consumer_readback_api.py \ tests/test_ai_agent_log_controlled_writeback_consumer_apply_api.py \ tests/test_ai_agent_autonomous_runtime_control.py \ tests/test_executor_trust_boundary_readback.py \ tests/test_ai_agent_report_truth_actionability_review.py \ tests/test_ai_agent_report_truth_actionability_review_api.py \ tests/test_awooop_truth_chain_service.py \ tests/test_signal_worker_ansible_executor_binding.py \ tests/test_channel_hub_grouped_alert_events.py \ tests/test_shadow_auto_approve.py \ tests/test_destructive_patterns.py \ tests/test_approval_pending_visibility.py \ tests/test_awooop_operator_timeline_labels.py::test_outbound_timeline_title_labels_runbook_review \ tests/test_config_url_validation.py \ tests/test_delivery_closure_workbench_api.py \ tests/test_runtime_bootstrap_guards.py \ tests/test_backup_dr_target_inventory.py \ tests/test_backup_dr_target_inventory_api.py \ tests/test_backup_dr_readiness_matrix.py \ tests/test_backup_dr_readiness_matrix_api.py \ tests/test_gitea_repo_bundle_backup_readback_api.py \ tests/test_gitea_cicd_alert_receipt_chain_readback_api.py \ tests/test_gitea_capability_ci_cd_optimization_readback_api.py \ tests/test_credential_escrow_evidence_intake_readiness_api.py \ tests/test_gitea_private_inventory_p0_scorecard_api.py \ tests/test_gitea_workflow_runner_owner_attestation_request_api.py \ tests/test_reboot_auto_recovery_slo_scorecard_api.py \ tests/test_windows99_vmx_source_locator_readback_api.py \ tests/test_stockplatform_public_api_runtime_readback.py \ tests/test_stockplatform_public_api_controlled_recovery_preflight.py \ tests/test_harbor_registry_controlled_recovery_preflight.py \ tests/test_harbor_registry_controlled_recovery_receipt.py \ tests/test_iwooos_security_operating_system.py \ tests/test_awoooi_production_deploy_readback_blocker.py \ tests/test_awoooi_priority_work_order_readback_api.py \ tests/test_alertmanager_webhook_metrics.py \ tests/test_telegram_alert_ai_automation_matrix_api.py \ tests/test_telegram_alert_monitoring_coverage_readback_api.py \ tests/e2e_network_test.py::TestHMACVerification::test_valid_hmac_signature \ tests/test_p0_cicd_baseline_source_readiness_api.py \ tests/test_product_awoooi_manifest_standard_api.py \ tests/test_trust_drift_watchdog.py \ ../../ops/runner/test_read_public_gitea_actions_queue.py \ ../../ops/runner/test_cd_controlled_runtime_profile.py \ ../../ops/runner/test_check_awoooi_non110_runner_readiness.py \ ../../ops/runner/test_install_awoooi_non110_runner_user_service.py \ ../../ops/runner/test_register_awoooi_110_controlled_cd_lane_drain.py \ ../../ops/runner/test_check_awoooi_110_controlled_cd_lane_readiness.py \ ../../ops/runner/test_verify_awoooi_non110_cd_closure.py \ ../../scripts/backup/tests/test_backup_status_contract.py \ ../../scripts/backup/tests/test_gitea_bundle_sample_restore_dry_run.py \ ../../scripts/ops/tests/test_backup_health_textfile_exporter.py \ ../../scripts/ops/tests/test_docker_disk_pressure_retention_cleanup.py \ ../../scripts/ops/tests/test_gitea_queue_hook_backlog_playbook.py \ ../../scripts/ops/tests/test_host_runaway_process_exporter.py \ ../../scripts/ops/tests/test_host_pressure_alert_contract.py \ ../../scripts/ops/tests/test_alertmanager_webhook_config.py \ ../../scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py \ ../../scripts/reboot-recovery/tests/test_cold_start_monitor_bounded_probes.py \ ../../scripts/reboot-recovery/tests/test_momo_source_arrival_gate.py \ ../../scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_installer.py \ ../../scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py \ ../../scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py \ ../../scripts/reboot-recovery/tests/test_windows99_console_verify_artifact_validator.py \ ../../scripts/reboot-recovery/tests/test_188_host_hygiene_checklist.py \ ../../scripts/reboot-recovery/tests/test_post_start_quick_check_contract.py \ ../../scripts/reboot-recovery/tests/test_reboot_p0_operational_contract.py \ ../../scripts/reboot-recovery/tests/test_harbor_watchdog_contract.py \ ../../scripts/reboot-recovery/tests/test_recover_110_control_path_and_harbor_local.py \ ../../scripts/security/tests/test_gitea_private_inventory_p0_scorecard.py \ ../../scripts/security/tests/test_gitea_authenticated_inventory_payload_validator.py \ ../../scripts/security/tests/test_runtime_image_mirror_controller.py \ -v --tb=short -x -p no:cacheprovider \ 2>&1 | tee /tmp/pytest-output.txt; PYTEST_EXIT=${PIPESTATUS[0]} else python3.11 ../../scripts/ci/generate-cicd-artifact-receipts.py --repo-root ../.. --out-dir /tmp/awoooi-cicd-artifacts echo "package-release-promotion source_contract=awoooi-ci-package-release-promotion.json" echo "✅ full profile: running learning-chain E2E in isolated pytest process" DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://ci:ci@localhost/ci}" \ PYTHONFAULTHANDLER=1 python3.11 -m pytest tests/test_learning_chain_e2e.py -v --tb=short -p no:cacheprovider \ 2>&1 | tee /tmp/pytest-learning-chain-output.txt; LEARNING_CHAIN_EXIT=${PIPESTATUS[0]} cat /tmp/pytest-learning-chain-output.txt > /tmp/pytest-output.txt if [ "$LEARNING_CHAIN_EXIT" -ne 0 ]; then tail -60 /tmp/pytest-output.txt cleanup_pytest_workspace_cache exit "$LEARNING_CHAIN_EXIT" fi DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://ci:ci@localhost/ci}" \ PYTHONFAULTHANDLER=1 python3.11 -m pytest tests/ -v --tb=short -x -p no:cacheprovider \ --ignore=tests/integration \ --ignore=tests/test_anomaly_counter.py \ --ignore=tests/test_global_repair_cooldown.py \ --ignore=tests/test_redis_multisig.py \ --ignore=tests/test_model_regression.py \ --ignore=tests/test_prompt_validation.py \ --ignore=tests/test_learning_chain_e2e.py \ --ignore=tests/e2e_network_test.py \ 2>&1 | tee -a /tmp/pytest-output.txt; PYTEST_EXIT=${PIPESTATUS[0]} fi tail -60 /tmp/pytest-output.txt cleanup_pytest_workspace_cache exit $PYTEST_EXIT CI_SCRIPT docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-api-tests" \ -e AWOOOI_CD_TEST_PROFILE="${AWOOOI_CD_TEST_PROFILE:-full}" \ --cpus "2.0" \ --memory "6g" \ --memory-swap "8g" \ -v "$PWD:/workspace" \ -v /tmp/awoooi-api-tests.sh:/tmp/awoooi-api-tests.sh:ro \ -v awoooi-api-venv-cache:/opt/api-venv \ -w /workspace \ "${{ env.CI_IMAGE }}" \ bash /tmp/awoooi-api-tests.sh # ── 整合測試 B5 (2026-04-10) ────────────────────────────────────────── # B5 整合測試 — postgres-test 由 services: 提供,localhost:15432 直連 # 2026-04-10 Claude Sonnet 4.6: 用 psql 直連 localhost:15432 初始化 schema # (docker exec 在 act runner 內無法取得 service container name) # B5: Gitea act runner 的 services: 實作與 GitHub Actions 不同 # service container 啟動後需直連,但 act 的 container name 可能為空 # 2026-04-10 ogt: 改用 docker run 本地啟動取代 services: 宣告 # 2026-04-19 ogt + Claude Opus 4.7: cd 連續 2 次 fail (run 984/985) # 真因: act runner 把 ci-runner 跑在獨立 user-defined network, # pg-test-b5 預設用 host bridge → 兩邊隔離無法連 (172.17.0.2 timeout) # 修法: 把 pg-test-b5 加入 act task 的 network,用 container name 連線 - name: Integration Tests (B5 — 真實 DB) run: | if [ -z "${AWOOOI_CD_TEST_PROFILE:-}" ] && [ -f .awoooi-cd-test-profile ]; then AWOOOI_CD_TEST_PROFILE="$(tr -d '\r\n' < .awoooi-cd-test-profile)" export AWOOOI_CD_TEST_PROFILE fi echo "B5 effective test profile=${AWOOOI_CD_TEST_PROFILE:-full}" if [ "${AWOOOI_CD_TEST_PROFILE:-full}" = "controlled-runtime" ]; then echo "✅ controlled-runtime profile: B5 DB integration unchanged; skipping B5 for this narrow release lane" exit 0 fi if ! docker info >/dev/null 2>&1; then echo "BLOCKER b5_outer_docker_unavailable" echo "NEXT_ACTION ensure_cd_runner_host_can_start_pgvector_container_then_retry_cd" exit 65 fi B5_RUN_ID_RAW="${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}" B5_RUN_ID_SAFE="$(printf '%s' "$B5_RUN_ID_RAW" | tr -c 'A-Za-z0-9_.-' '-')" B5_NET="b5-test-net-${B5_RUN_ID_SAFE}" B5_DB_CONTAINER="pg-test-b5-${B5_RUN_ID_SAFE}" cleanup_b5() { docker rm -f "$B5_DB_CONTAINER" >/dev/null 2>&1 || true docker network rm "$B5_NET" >/dev/null 2>&1 || true } trap cleanup_b5 EXIT cleanup_b5 docker network create "$B5_NET" >/dev/null docker run -d --name "$B5_DB_CONTAINER" \ --network="$B5_NET" \ --network-alias pg-test-b5 \ -e POSTGRES_DB=awoooi_test \ -e POSTGRES_USER=awoooi \ -e POSTGRES_PASSWORD=awoooi_test_2026 \ pgvector/pgvector:pg16 B5_DB_READY=0 for i in $(seq 1 30); do if docker exec "$B5_DB_CONTAINER" pg_isready -U awoooi -d awoooi_test; then B5_DB_READY=1 break fi sleep 2 done if [ "$B5_DB_READY" != "1" ]; then echo "BLOCKER b5_pg_test_container_not_ready" echo "NEXT_ACTION inspect_b5_test_network_and_outer_docker_then_retry_cd" docker ps --filter "name=$B5_DB_CONTAINER" --format 'b5_container={{.Names}} status={{.Status}}' || true exit 66 fi cat > /tmp/awoooi-b5-tests.sh <<'CI_SCRIPT' set -euo pipefail cd apps/api # 安裝 psql client if ! command -v psql &>/dev/null; then apt-get install -y -q postgresql-client fi B5_DB_HOST="${B5_DB_HOST:-pg-test-b5}" # 初始化 schema PGPASSWORD=awoooi_test_2026 psql \ -h "$B5_DB_HOST" -p 5432 -U awoooi -d awoooi_test \ -f tests/integration/setup_test_schema.sql # 跑測試 # B5 整合測試嚴格模式 (2026-04-13 ogt: 恢復 Break-Glass 移除) # -m integration: override pyproject.toml addopts "-m 'not integration'",讓標記測試可執行 # 2026-04-22 ogt: DATABASE_URL 改為必填後,import chain 需要此 env var 讓 Settings 通過驗證 DATABASE_URL="postgresql+asyncpg://awoooi:awoooi_test_2026@${B5_DB_HOST}:5432/awoooi_test?ssl=disable" \ TEST_DATABASE_URL="postgresql+asyncpg://awoooi:awoooi_test_2026@${B5_DB_HOST}:5432/awoooi_test?ssl=disable" \ /opt/api-venv/bin/pytest tests/integration/test_b5_core_flows.py -v --tb=short -m integration -p no:cacheprovider || PYTEST_EXIT=$? # pg-test-b5 is cleaned by the outer runner trap after this container exits. # 2026-05-20 Codex: B5 imports shared tests helpers, so cleanup the # whole tests tree to avoid root-owned __pycache__ act-runner noise. find tests src -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true rm -rf .pytest_cache 2>/dev/null || true exit "${PYTEST_EXIT:-0}" CI_SCRIPT docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-b5-tests" \ --user 0:0 \ --cpus "2.0" \ --memory "2g" \ --network "$B5_NET" \ -e B5_DB_HOST=pg-test-b5 \ -v "$PWD:/workspace" \ -v /tmp/awoooi-b5-tests.sh:/tmp/awoooi-b5-tests.sh:ro \ -v awoooi-api-venv-cache:/opt/api-venv \ -w /workspace \ "${{ env.CI_IMAGE }}" \ bash /tmp/awoooi-b5-tests.sh - name: Clean Test Workspace Artifacts if: always() env: HOST_RUNNER_CLEANUP_IMAGE: ${{ env.CI_IMAGE }} run: bash scripts/ci/cleanup-host-runner-workspace.sh - name: Notify Pipeline Failure # 2026-04-30 Codex: tests job failure notifier; no jq dependency for host parity. if: failure() env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} run: | COMMIT_MSG="${{ steps.commit.outputs.message }}" SHORT_SHA="${{ steps.commit.outputs.short_sha }}" ACTOR="${{ github.actor }}" COMMIT_ESC=$(echo "$COMMIT_MSG" | sed 's/&/\&/g; s//\>/g') MSG=$(printf '❌ AWOOOI 部署失敗\n├ 📝 %s\n├ 🔖 %s\n├ 👤 %s\n├ 🧪 Stage: tests\n└ 🔗 http://192.168.0.110:3001/wooo/awoooi/actions' "${COMMIT_ESC}" "${SHORT_SHA}" "${ACTOR}") if AWOOI_CICD_STATUS=failed \ AWOOI_CICD_STAGE=tests \ AWOOI_CICD_JOB_NAME="AWOOOI 部署失敗" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_SUMMARY="${COMMIT_MSG}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD tests failure notification mirrored through AWOOI API" else echo "AWOOI API notify failed; direct Telegram fallback disabled to preserve AwoooP receipt chain" fi build-and-deploy: # 2026-06-28 Codex: keep CD-generated `[skip ci]` deploy commits and # `cancel-stale-cd` queue-cleaning commits from re-entering build/deploy. # 2026-07-01 Codex: metadata-only controlled-runtime fixes already run the # focused tests above; do not spend the Docker build lock or deploy marker # when no app image can change. if: ${{ github.event_name != 'push' || (!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, 'cancel-stale-cd') && !contains(github.event.head_commit.message, '[metadata-only]')) }} # 2026-04-30 Codex: Docker builds run on the host runner. Long docker build # steps were killing the transient act job container with RWLayer=nil. needs: [tests] timeout-minutes: 60 runs-on: awoooi-non110-host steps: - name: Bootstrap Host Runner Tools # 2026-05-05 Codex: keep the host-mode runner self-healing before # actions/checkout@v4 and Telegram failure notifications run. run: | if command -v apk >/dev/null 2>&1; then apk add --no-cache nodejs npm git curl bash coreutils python3 openssh-client docker-cli docker-cli-buildx fi - name: Checkout source from Gitea env: GITEA_SOURCE_URL: http://192.168.0.110:3001/wooo/awoooi.git run: | set -euo pipefail git init . git remote remove origin 2>/dev/null || true git remote add origin "$GITEA_SOURCE_URL" git fetch --no-tags --prune --depth=1 origin "$GITHUB_SHA" # Deploy marker creation and push require a real local main ref. git checkout --force -B main FETCH_HEAD - name: Wait for Host Web Build Pressure # 2026-06-27 Codex: post-deploy smoke is also browser-heavy. Refuse to # add another smoke run while active CI/build/smoke pressure is present. run: bash scripts/ci/wait-host-web-build-pressure.sh - name: Get Commit Info id: commit run: | echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT echo "message=$(git log -1 --pretty=%s | head -c 50)" >> $GITHUB_OUTPUT echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT - name: Notify Build Deploy Start run: | ACTOR="${{ github.actor }}" if AWOOI_CICD_STATUS=running \ AWOOI_CICD_STAGE=build-and-deploy \ AWOOI_CICD_JOB_NAME="AWOOOI 建置部署開始" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_SUMMARY="${{ steps.commit.outputs.message }}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD build-deploy start notification mirrored through AWOOI API" else echo "⚠️ CI/CD build-deploy start notification failed (non-fatal)" fi - name: Login to Harbor env: HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} run: | HARBOR_REGISTRY="${{ env.HARBOR }}" LOGIN_ATTEMPTS="${HARBOR_LOGIN_ATTEMPTS:-3}" LOGIN_SLEEP_SECONDS="${HARBOR_LOGIN_SLEEP_SECONDS:-5}" WATCHDOG="/usr/local/bin/harbor-watchdog.sh" HARBOR_110_NODE_EXPORTER_URL="${HARBOR_110_NODE_EXPORTER_URL:-http://192.168.0.110:9100/metrics}" HARBOR_110_MAX_LOAD5_PER_CORE="${HARBOR_110_MAX_LOAD5_PER_CORE:-1.25}" HARBOR_110_MAX_GITEA_CPU_CORES="${HARBOR_110_MAX_GITEA_CPU_CORES:-3.0}" probe_registry_v2() { curl --silent --show-error \ --output /dev/null \ --write-out "%{http_code}" \ --max-time 10 \ "https://${HARBOR_REGISTRY}/v2/" || true } host_has_110_ip() { command -v ip >/dev/null 2>&1 || return 1 ip -o -4 addr show 2>/dev/null | grep -q " 192.168.0.110/" } run_watchdog() { if [ "$(id -u)" = "0" ]; then "${WATCHDOG}" "$@" else sudo -n "${WATCHDOG}" "$@" fi } greater_than() { awk -v left="$1" -v right="$2" 'BEGIN { exit !(left > right) }' } preflight_110_capacity() { metrics="$(curl --silent --show-error --max-time 5 "${HARBOR_110_NODE_EXPORTER_URL}" || true)" if [ -z "${metrics}" ]; then echo "harbor_110_capacity_metrics_unavailable=1" return 0 fi load5_per_core="$(printf '%s\n' "${metrics}" | awk '$1 ~ /^awoooi_host_load5_per_core\\{/ && $0 ~ /host=\"110\"/ { value = $NF } END { print value }')" gitea_cpu_cores="$(printf '%s\n' "${metrics}" | awk '$1 ~ /^docker_container_cpu_cores\\{/ && $0 ~ /container_name=\"gitea\"/ { value = $NF } END { print value }')" if [ -n "${load5_per_core}" ]; then echo "harbor_110_load5_per_core=${load5_per_core}" if greater_than "${load5_per_core}" "${HARBOR_110_MAX_LOAD5_PER_CORE}"; then echo "BLOCKER harbor_110_host_pressure_high load5_per_core=${load5_per_core} threshold=${HARBOR_110_MAX_LOAD5_PER_CORE}" return 1 fi fi if [ -n "${gitea_cpu_cores}" ]; then echo "harbor_110_gitea_cpu_cores=${gitea_cpu_cores}" if greater_than "${gitea_cpu_cores}" "${HARBOR_110_MAX_GITEA_CPU_CORES}"; then echo "BLOCKER harbor_110_gitea_cpu_saturated cpu_cores=${gitea_cpu_cores} threshold=${HARBOR_110_MAX_GITEA_CPU_CORES}" return 1 fi fi return 0 } controlled_harbor_repair_once() { if [ "${AWOOOI_CD_HARBOR_CONTROLLED_REPAIR:-1}" != "1" ]; then echo "harbor_controlled_repair_skipped=disabled" return 1 fi if ! host_has_110_ip; then echo "harbor_controlled_repair_skipped=not_110_host" return 1 fi if [ ! -x "${WATCHDOG}" ]; then echo "harbor_controlled_repair_skipped=watchdog_missing" return 1 fi echo "harbor_controlled_repair_check_start=1" set +e check_output="$(run_watchdog --check 2>&1)" check_rc=$? set -e printf '%s\n' "${check_output}" | sed -n '1,80p' echo "harbor_controlled_repair_check_rc=${check_rc}" if [ "${check_rc}" -ne 0 ]; then return 1 fi if ! printf '%s\n' "${check_output}" | grep -q "AWOOOI_HARBOR_WATCHDOG_CHECK"; then echo "harbor_controlled_repair_blocked=missing_watchdog_check_marker" return 1 fi if printf '%s\n' "${check_output}" | grep -q "harbor_ready=true"; then echo "harbor_controlled_repair_skipped=already_ready" return 0 fi echo "harbor_controlled_repair_once_start=1" set +e repair_output="$(run_watchdog --repair-once 2>&1)" repair_rc=$? set -e printf '%s\n' "${repair_output}" | sed -n '1,140p' echo "harbor_controlled_repair_once_rc=${repair_rc}" [ "${repair_rc}" -eq 0 ] } attempt=1 repair_attempted=0 if ! preflight_110_capacity; then echo "NEXT_ACTION wait_for_110_load_to_normalize_then_rerun_harbor_watchdog_check" echo "NEXT_ACTION if_110_load_stays_high_use_local_console: sudo /usr/local/bin/recover-110-control-path-and-harbor-local.sh --check" exit 1 fi while [ "${attempt}" -le "${LOGIN_ATTEMPTS}" ]; do registry_status="$(probe_registry_v2)" if [ -z "${registry_status}" ]; then registry_status="000" fi if [ "${registry_status}" = "200" ] || [ "${registry_status}" = "401" ]; then if printf '%s\n' "${HARBOR_PASSWORD}" | \ docker login "${HARBOR_REGISTRY}" \ -u "${HARBOR_USERNAME}" \ --password-stdin; then echo "harbor_login_ready=1" exit 0 fi echo "harbor_login_attempt=${attempt} docker_login_failed" else echo "harbor_login_attempt=${attempt} registry_v2_status=${registry_status}" if [ "${repair_attempted}" = "0" ]; then repair_attempted=1 if controlled_harbor_repair_once; then registry_status="$(probe_registry_v2)" if [ -z "${registry_status}" ]; then registry_status="000" fi echo "harbor_controlled_repair_public_registry_v2_status=${registry_status}" continue fi fi fi if [ "${attempt}" -ge "${LOGIN_ATTEMPTS}" ]; then echo "BLOCKER harbor_registry_public_route_unavailable registry_v2_status=${registry_status}" echo "NEXT_ACTION run_on_110_local_console_or_restored_ssh: sudo /usr/local/bin/harbor-watchdog.sh --check" echo "NEXT_ACTION if_check_confirms_unhealthy_on_110: sudo /usr/local/bin/harbor-watchdog.sh --repair-once" echo "NEXT_ACTION combined_110_control_path_then_harbor: sudo /usr/local/bin/recover-110-control-path-and-harbor-local.sh --apply-all" echo "NEXT_ACTION controlled_workflow_dispatch: .gitea/workflows/harbor-110-local-repair.yaml" exit 1 fi sleep "${LOGIN_SLEEP_SECONDS}" attempt=$((attempt + 1)) done # 2026-05-21 Codex: AWOOI workflow concurrency and the Docker network # lock only protect AWOOI/Docker work. Other repos can still run # host-side Next/Turbo builds on the same 110 runner and starve this # deploy. Wait for those foreign web builds before starting our image # build; the gate is read-only and never kills another process. - name: Wait for Host Web Build Pressure run: bash scripts/ci/wait-host-web-build-pressure.sh # 2026-04-30 Codex: Gitea act-runner shares one Docker daemon across repos. # When another repo starts a heavy docker build while AWOOOI Web is still # building, the job container can disappear and Docker reports RWLayer=nil. # A Docker-network lock is global to the host daemon and survives container # namespaces, unlike /tmp/flock inside the transient job container. # 2026-06-28 Codex: 110 runner pressure remains incident-grade; Docker # build lock contention is fail-hard until non-110 readiness is verified. - name: Acquire Docker Build Lock run: | LOCK_NAME="awoooi-cd-docker-build-lock" LOCK_WARN_ONLY="${DOCKER_BUILD_LOCK_WARN_ONLY:-0}" STALE_SECONDS="${DOCKER_BUILD_LOCK_STALE_SECONDS:-7200}" EMPTY_LOCK_SECONDS="${DOCKER_BUILD_LOCK_EMPTY_SECONDS:-300}" WAIT_ATTEMPTS="${DOCKER_BUILD_LOCK_WAIT_ATTEMPTS:-180}" WAIT_SLEEP_SECONDS="${DOCKER_BUILD_LOCK_SLEEP_SECONDS:-10}" for attempt in $(seq 1 "$WAIT_ATTEMPTS"); do if docker network create \ --label awoooi.ci-lock=docker-build \ --label awoooi.owner=cd-pipeline \ "$LOCK_NAME" >/dev/null 2>&1; then echo "DOCKER_BUILD_LOCK=${LOCK_NAME}" >> "$GITHUB_ENV" echo "✅ Docker build lock acquired: ${LOCK_NAME}" exit 0 fi CREATED_AT=$(docker network inspect "$LOCK_NAME" \ --format '{{.Created}}' 2>/dev/null || true) if [ -n "$CREATED_AT" ]; then # 2026-05-03 ogt: 修復 stale 偵測 — Docker 回傳 "2006-01-02 15:04:05.999999999 -0700 MST" # date -d 不接受奈秒小數點與末尾時區縮寫(CST/MST 等),導致 CREATED_EPOCH=0 → stale 永不觸發 # 2026-06-18 Codex: act-runner 容器可能沒有 GNU date / python3; # node 由 bootstrap 安裝,作為 Docker CreatedAt 的穩定解析 fallback。 # 2026-06-19 Codex: Docker / Gitea runner 可能回傳 ISO # `2026-06-18T16:20:00.123456789Z`;若 CREATED_EPOCH=0, # empty lock 永遠不會自清,下一輪 deploy 會卡滿 30 分鐘。 CREATED_CLEAN=$(echo "$CREATED_AT" | sed 's/\.[0-9]*//' | sed 's/ [A-Z][A-Z]*$//') CREATED_EPOCH=$(date -d "$CREATED_CLEAN" +%s 2>/dev/null || \ node -e 'const raw = process.argv[1] || ""; const base = raw.replace(/\.\d+/, "").replace(/\s+[A-Z]{2,4}$/, ""); const spaced = base.replace(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+([+-]\d{2})(\d{2})$/, "$1T$2$3:$4"); const iso = base.replace(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:?\d{2})$/, "$1T$2$3"); const candidates = [raw, base, spaced, iso]; for (const candidate of candidates) { const ms = Date.parse(candidate); if (Number.isFinite(ms)) { console.log(Math.floor(ms / 1000)); process.exit(0); } } process.exit(1);' \ "$CREATED_AT" 2>/dev/null || \ python3 -c "import sys, datetime, re; ts = re.sub(r'\\.\d+', '', sys.argv[1]); ts = re.sub(r'\\s+[A-Z]{2,4}$', '', ts.strip()); print(int(datetime.datetime.strptime(ts, '%Y-%m-%d %H:%M:%S %z').timestamp()))" \ "$CREATED_AT" 2>/dev/null || echo 0) NOW_EPOCH=$(date +%s) LOCK_AGE=$((NOW_EPOCH - CREATED_EPOCH)) # 2026-05-05 Codex: dirty reboot / cancelled Actions can leave # the Docker-network lock behind with no active build or push. # Waiting the full 30m CD timeout keeps deploys queued even # though no job is protected, so clear empty locks after 5m. # 2026-06-18 Codex: 只靠 bracket pattern 仍會命中 lock-check # bash/awk 自己的指令列;必須排除檢查器本身,取消後留下的 # empty lock network 才能在 5 分鐘後自清。 ACTIVE_DOCKER_WORK=$(ps -eo pid,args | awk ' $0 ~ /[d]ocker (build|push)|[b]uildx build/ && $0 !~ /ACTIVE_DOCKER_WORK/ && $0 !~ /awk/ && $0 !~ /ps -eo pid,args/ {print} ' || true) if [ "$CREATED_EPOCH" -eq 0 ] && \ [ $((attempt * WAIT_SLEEP_SECONDS)) -gt $((EMPTY_LOCK_SECONDS * 2)) ] && \ [ -z "$ACTIVE_DOCKER_WORK" ]; then echo "⚠️ Docker build lock has unparsable CreatedAt (${CREATED_AT}) and no active docker build/push after $((attempt * WAIT_SLEEP_SECONDS))s, removing ${LOCK_NAME}" docker network rm "$LOCK_NAME" >/dev/null 2>&1 || true continue fi if [ "$CREATED_EPOCH" -gt 0 ] && \ [ "$LOCK_AGE" -gt "$EMPTY_LOCK_SECONDS" ] && \ [ -z "$ACTIVE_DOCKER_WORK" ]; then echo "⚠️ empty Docker build lock detected (age=${LOCK_AGE}s > ${EMPTY_LOCK_SECONDS}s, no active docker build/push), removing ${LOCK_NAME}" docker network rm "$LOCK_NAME" >/dev/null 2>&1 || true continue fi if [ "$CREATED_EPOCH" -gt 0 ] && \ [ "$LOCK_AGE" -gt "$STALE_SECONDS" ]; then echo "⚠️ stale Docker build lock detected (age=${LOCK_AGE}s > ${STALE_SECONDS}s), removing ${LOCK_NAME}" docker network rm "$LOCK_NAME" >/dev/null 2>&1 || true continue fi fi echo "⏳ Docker build lock busy (attempt ${attempt}/${WAIT_ATTEMPTS}); waiting ${WAIT_SLEEP_SECONDS}s..." if [ "$attempt" -lt "$WAIT_ATTEMPTS" ]; then sleep "$WAIT_SLEEP_SECONDS" fi done echo "⚠️ timed out waiting for Docker build lock" if [ "$LOCK_WARN_ONLY" = "1" ]; then echo "⚠️ continuing without exclusive Docker build lock under commander controlled automation" exit 0 fi echo "❌ refusing to continue without Docker build lock" exit 1 - name: Mirror Runtime Cache Images to Harbor run: | set -euo pipefail prepare_deploy_key() { mkdir -p "${HOME}/.ssh" umask 077 local source_key="${AWOOOI_DEPLOY_SSH_KEY_PATH:-${HOME}/.ssh/deploy_key}" if [ ! -r "${source_key}" ]; then echo "BLOCKER runtime_image_mirror_deploy_key_missing" exit 1 fi if [ "${source_key}" != "${HOME}/.ssh/deploy_key" ]; then cp "${source_key}" "${HOME}/.ssh/deploy_key" fi chmod 600 "${HOME}/.ssh/deploy_key" } prepare_deploy_key CACHE_KNOWN_HOSTS="${HOME}/.ssh/runtime_cache_known_hosts" ssh-keyscan -T 5 -t ed25519,rsa,ecdsa 192.168.0.120 \ > "${CACHE_KNOWN_HOSTS}" 2>/dev/null test -s "${CACHE_KNOWN_HOSTS}" || { echo "BLOCKER runtime_image_cache_host_key_unavailable" exit 1 } STAGING_TRACE_ID="aia-p0-006-02b-${GITHUB_RUN_ID:-${GITEA_RUN_NUMBER:-unknown}}" STAGING_RECEIPT_PATH="/tmp/awoooi-runtime-image-staging-receipt.json" rm -f "${STAGING_RECEIPT_PATH}" python3 scripts/security/runtime_image_mirror_controller.py \ --policy config/security/runtime-image-mirror-staging-policy.json \ --trace-id "${STAGING_TRACE_ID}" \ stage \ --apply \ --receipt "${STAGING_RECEIPT_PATH}" \ --ssh-key "${HOME}/.ssh/deploy_key" \ --known-hosts "${CACHE_KNOWN_HOSTS}" test -s "${STAGING_RECEIPT_PATH}" echo "runtime_image_staging_stage=verified" TRACE_ID="aia-p0-006-02a-${GITHUB_RUN_ID:-${GITEA_RUN_NUMBER:-unknown}}" RECEIPT_PATH="/tmp/awoooi-runtime-image-mirror-receipt.json" rm -f "${RECEIPT_PATH}" python3 scripts/security/runtime_image_mirror_controller.py \ --policy config/security/runtime-image-mirror-policy.json \ --trace-id "${TRACE_ID}" \ mirror \ --apply \ --receipt "${RECEIPT_PATH}" \ --ssh-key "${HOME}/.ssh/deploy_key" \ --known-hosts "${CACHE_KNOWN_HOSTS}" test -s "${RECEIPT_PATH}" echo "runtime_image_mirror_stage=verified" # ── API 鏡像建置(含 Layer Cache 加速)────────────────────────────── # 2026-04-01 ogt: CACHE_BUST=git_sha 確保 src/ 和 models.json 層每次重建 # deps 層 (pip install) 仍可 cache → 加速;代碼/配置層強制失效 # 2026-05-05 Codex: host runner bootstrap installs docker-cli-buildx; # keep BuildKit enabled because the web Dockerfile uses RUN --mount. - name: Build and Push API env: DOCKER_BUILDKIT: "1" run: | run_docker_step() { local label="$1" local timeout_seconds="$2" shift 2 echo "cd_docker_step_start=${label} timeout_seconds=${timeout_seconds}" set +e if command -v timeout >/dev/null 2>&1; then timeout -k 30s "${timeout_seconds}s" "$@" else "$@" fi local rc=$? set -e echo "cd_docker_step_rc=${label}:${rc}" if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then echo "BLOCKER cd_docker_step_timeout label=${label} timeout_seconds=${timeout_seconds}" echo "NEXT_ACTION inspect_cd_runner_docker_build_push_or_registry_route_then_rerun_cd" fi return "$rc" } run_docker_push_step() { local label="$1" local timeout_seconds="$2" shift 2 local max_attempts="${CD_DOCKER_PUSH_ATTEMPTS:-3}" local sleep_seconds="${CD_DOCKER_PUSH_RETRY_SLEEP_SECONDS:-15}" local attempt=1 local rc=1 while [ "${attempt}" -le "${max_attempts}" ]; do echo "cd_docker_push_attempt=${label}:${attempt}/${max_attempts}" if run_docker_step "${label}" "${timeout_seconds}" "$@"; then rc=0 else rc=$? fi if [ "${rc}" -eq 0 ]; then return 0 fi if [ "${attempt}" -ge "${max_attempts}" ]; then echo "BLOCKER cd_docker_push_failed label=${label} attempts=${max_attempts}" echo "NEXT_ACTION inspect_harbor_registry_push_500_or_storage_pressure_then_rerun_cd" return "${rc}" fi echo "cd_docker_push_retry_sleep=${label}:${sleep_seconds}" sleep "${sleep_seconds}" attempt=$((attempt + 1)) done return "${rc}" } run_docker_step api_build "${DOCKER_API_BUILD_TIMEOUT_SECONDS:-1200}" \ docker build -f apps/api/Dockerfile \ --build-arg BUILDKIT_INLINE_CACHE=1 \ --cache-from ${{ env.HARBOR }}/awoooi/api:latest \ --build-arg CACHE_BUST=${{ github.sha }} \ -t ${{ env.HARBOR }}/awoooi/api:${{ github.sha }} \ -t ${{ env.HARBOR }}/awoooi/api:latest \ . run_docker_push_step api_push_sha "${DOCKER_API_PUSH_TIMEOUT_SECONDS:-600}" \ docker push ${{ env.HARBOR }}/awoooi/api:${{ github.sha }} run_docker_push_step api_push_latest "${DOCKER_API_PUSH_TIMEOUT_SECONDS:-600}" \ docker push ${{ env.HARBOR }}/awoooi/api:latest API_IMAGE_DIGEST="$( docker manifest inspect --verbose \ "${{ env.HARBOR }}/awoooi/api:${{ github.sha }}" | \ python3 -c ' import json import re import sys payload = json.load(sys.stdin) digest = payload.get("Descriptor", {}).get("digest", "") if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): raise SystemExit("api_harbor_digest_invalid") print(digest) ' )" docker manifest inspect \ "${{ env.HARBOR }}/awoooi/api@${API_IMAGE_DIGEST}" >/dev/null echo "API_IMAGE_DIGEST=${API_IMAGE_DIGEST}" >> "${GITHUB_ENV}" echo "api_harbor_digest_verified=${API_IMAGE_DIGEST}" # 2026-03-31 ogt: 移除中間通知,減少訊息雜訊 # ── Web 鏡像建置(精準快取失效)────────────────────────────── # 2026-03-30 ogt: NEXT_PUBLIC_* 必須用公網域名 (build-time 寫死) # 2026-04-01 Claude Code: CACHE_BUST=git_sha 取代 --no-cache # - deps 層 (pnpm install) 仍可 cache → 節省 ~2-3 min # - COPY . . 以下由 CACHE_BUST 強制失效 → 業務邏輯/CSRF 等變更正確進入 bundle # 2026-05-05 Codex: mirror API build mode; BuildKit required for cache mounts. - name: Build and Push Web env: DOCKER_BUILDKIT: "1" run: | run_docker_step() { local label="$1" local timeout_seconds="$2" shift 2 echo "cd_docker_step_start=${label} timeout_seconds=${timeout_seconds}" set +e if command -v timeout >/dev/null 2>&1; then timeout -k 30s "${timeout_seconds}s" "$@" else "$@" fi local rc=$? set -e echo "cd_docker_step_rc=${label}:${rc}" if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then echo "BLOCKER cd_docker_step_timeout label=${label} timeout_seconds=${timeout_seconds}" echo "NEXT_ACTION inspect_cd_runner_docker_build_push_or_registry_route_then_rerun_cd" fi return "$rc" } run_docker_push_step() { local label="$1" local timeout_seconds="$2" shift 2 local max_attempts="${CD_DOCKER_PUSH_ATTEMPTS:-3}" local sleep_seconds="${CD_DOCKER_PUSH_RETRY_SLEEP_SECONDS:-15}" local attempt=1 local rc=1 while [ "${attempt}" -le "${max_attempts}" ]; do echo "cd_docker_push_attempt=${label}:${attempt}/${max_attempts}" if run_docker_step "${label}" "${timeout_seconds}" "$@"; then rc=0 else rc=$? fi if [ "${rc}" -eq 0 ]; then return 0 fi if [ "${attempt}" -ge "${max_attempts}" ]; then echo "BLOCKER cd_docker_push_failed label=${label} attempts=${max_attempts}" echo "NEXT_ACTION inspect_harbor_registry_push_500_or_storage_pressure_then_rerun_cd" return "${rc}" fi echo "cd_docker_push_retry_sleep=${label}:${sleep_seconds}" sleep "${sleep_seconds}" attempt=$((attempt + 1)) done return "${rc}" } # 2026-07-03 Codex: run #4610 proved the Harbor DockerHub proxy # cache path returned 401 for node:20-alpine. Keep the public tag # until proxy-cache pull auth has a deploy-log receipt. Pull it with # bounded retries and retag locally so transient DockerHub HEAD 500s # do not block source-only UI/CD releases after one failed attempt. prepare_web_node_base_image() { local local_tag="awoooi-web-node-base:20-alpine" local source_image="node:20-alpine" local attempt local pull_rc for attempt in 1 2 3; do echo "web_node_base_pull_candidate=${source_image} attempt=${attempt}" set +e if command -v timeout >/dev/null 2>&1; then timeout -k 10s 120s docker pull "${source_image}" else docker pull "${source_image}" fi pull_rc=$? set -e echo "web_node_base_pull_rc=${source_image}:${pull_rc}" if [ "${pull_rc}" -eq 0 ]; then docker tag "${source_image}" "${local_tag}" WEB_NODE_BASE_IMAGE="${local_tag}" export WEB_NODE_BASE_IMAGE echo "web_node_base_image_selected=${WEB_NODE_BASE_IMAGE}" return 0 fi sleep 5 done echo "BLOCKER web_node_base_image_pull_failed" echo "NEXT_ACTION inspect_dockerhub_route_or_harbor_proxy_auth_then_rerun_cd" return 1 } prepare_web_node_base_image run_docker_step web_build "${DOCKER_WEB_BUILD_TIMEOUT_SECONDS:-1500}" \ docker build -f apps/web/Dockerfile \ --build-arg NODE_BASE_IMAGE="${WEB_NODE_BASE_IMAGE}" \ --build-arg NEXT_PUBLIC_API_URL=https://awoooi.wooo.work \ --build-arg CACHE_BUST=${{ github.sha }} \ --build-arg BUILDKIT_INLINE_CACHE=1 \ --cache-from ${{ env.HARBOR }}/awoooi/web:latest \ -t ${{ env.HARBOR }}/awoooi/web:${{ github.sha }} \ -t ${{ env.HARBOR }}/awoooi/web:latest \ . run_docker_push_step web_push_sha "${DOCKER_WEB_PUSH_TIMEOUT_SECONDS:-600}" \ docker push ${{ env.HARBOR }}/awoooi/web:${{ github.sha }} run_docker_push_step web_push_latest "${DOCKER_WEB_PUSH_TIMEOUT_SECONDS:-600}" \ docker push ${{ env.HARBOR }}/awoooi/web:latest WEB_IMAGE_DIGEST="$( docker manifest inspect --verbose \ "${{ env.HARBOR }}/awoooi/web:${{ github.sha }}" | \ python3 -c ' import json import re import sys payload = json.load(sys.stdin) digest = payload.get("Descriptor", {}).get("digest", "") if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): raise SystemExit("web_harbor_digest_invalid") print(digest) ' )" docker manifest inspect \ "${{ env.HARBOR }}/awoooi/web@${WEB_IMAGE_DIGEST}" >/dev/null echo "WEB_IMAGE_DIGEST=${WEB_IMAGE_DIGEST}" >> "${GITHUB_ENV}" echo "web_harbor_digest_verified=${WEB_IMAGE_DIGEST}" - name: Release Docker Build Lock if: always() run: | if [ -n "${DOCKER_BUILD_LOCK:-}" ]; then docker network rm "$DOCKER_BUILD_LOCK" >/dev/null 2>&1 || true echo "✅ Docker build lock released: ${DOCKER_BUILD_LOCK}" else echo "⚡ no Docker build lock to release" fi # Production drift proved the original CREATE TABLE IF NOT EXISTS # migration did not update an older CHECK constraint. Reconcile this # one additive allowlist before rolling out the scanner and verify it # independently without exposing connection details. - name: Reconcile Asset Inventory Type Constraint env: DATABASE_URL: ${{ secrets.DATABASE_URL }} MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }} run: | set -euo pipefail if [ -z "${MIGRATION_DATABASE_URL:-}" ]; then echo "::error::MIGRATION_DATABASE_URL secret not set in Gitea" exit 1 fi # The migration target is a routed LAN endpoint; the default bridge # keeps the verifier compatible with the runner's userns-remap policy. docker run --rm --network bridge -i \ -e DATABASE_URL \ -e MIGRATION_DATABASE_URL \ -v "$PWD/apps/api/migrations/asset_inventory_database_types_2026-07-14.sql:/tmp/asset_inventory_type.sql:ro" \ "${HARBOR}/awoooi/api:${{ github.sha }}" python - <<'PY' import asyncio import os import re from pathlib import Path import asyncpg MIGRATION = Path("/tmp/asset_inventory_type.sql").read_text(encoding="utf-8") REQUIRED_TYPES = { "host", "container", "k8s_workload", "k8s_resource", "database", "table", "website", "api_endpoint", "package", "log_stream", "km_entry", "frontend", "backend", "ci_pipeline", "gitea_repo", "monitoring_target", "secret", "volume", "network", "certificate", "scheduled_job", "message_queue", "cache", "dashboard", "ai_agent", "llm_model", "third_party_service", "backup_target", } def normalize_url(value: str) -> str: return value.replace("postgresql+asyncpg://", "postgresql://", 1) async def constraint_status( connection: asyncpg.Connection, ) -> tuple[bool, str, bool]: row = await connection.fetchrow( """ SELECT constraint_row.convalidated, pg_get_constraintdef(constraint_row.oid) AS definition, EXISTS ( SELECT 1 FROM automation_operation_log WHERE actor = 'gitea_cd_asset_schema_reconciler' AND operation_type = 'asset_discovered' AND status = 'success' AND input ->> 'migration' = 'asset_inventory_database_types_2026-07-14' ) AS durable_receipt_present FROM pg_constraint constraint_row WHERE constraint_row.conrelid = 'asset_inventory'::regclass AND constraint_row.conname = 'asset_inventory_type_valid' """ ) if row is None: return False, "", False return ( bool(row["convalidated"]), str(row["definition"] or ""), bool(row["durable_receipt_present"]), ) def allowed_types(definition: str) -> set[str]: return set(re.findall(r"'([^']+)'(?:::text)?", definition)) async def reconcile(url: str) -> tuple[bool, str, bool, bool]: connection = await asyncpg.connect(normalize_url(url), timeout=10) applied = False try: validated, definition, receipt_present = await constraint_status(connection) if ( not validated or allowed_types(definition) != REQUIRED_TYPES or not receipt_present ): async with connection.transaction(): await connection.execute(MIGRATION) applied = True validated, definition, receipt_present = await constraint_status(connection) return validated, definition, receipt_present, applied finally: await connection.close() async def main() -> None: candidates = [ ("migration_role", os.environ["MIGRATION_DATABASE_URL"]), ("owner_role", os.environ.get("DATABASE_URL", "")), ] for index, (role, url) in enumerate(candidates): if not url: continue try: validated, definition, receipt_present, applied = await reconcile(url) type_set = allowed_types(definition) database_allowed = "database" in type_set table_allowed = "table" in type_set type_set_exact = type_set == REQUIRED_TYPES print(f"asset_inventory_type_migration_role={role}") print(f"asset_inventory_type_migration_applied={str(applied).lower()}") print(f"database_type_allowed={str(database_allowed).lower()}") print(f"table_type_allowed={str(table_allowed).lower()}") print(f"constraint_type_set_exact={str(type_set_exact).lower()}") print(f"constraint_validated={str(validated).lower()}") print(f"durable_receipt_present={str(receipt_present).lower()}") if not ( validated and database_allowed and table_allowed and type_set_exact and receipt_present ): raise RuntimeError("asset_inventory_type_constraint_verifier_failed") return except asyncpg.PostgresError as exc: permission_denied = getattr(exc, "sqlstate", "") == "42501" has_owner_fallback = index == 0 and bool(candidates[1][1]) if permission_denied and has_owner_fallback: print("asset_inventory_type_migration_owner_fallback=true") continue print(f"asset_inventory_type_migration_error_class={type(exc).__name__}") raise raise RuntimeError("asset_inventory_type_migration_no_usable_connection") asyncio.run(main()) PY # A valid unique index can still hide heap/index drift introduced by an # older restore or repair. Quarantine only the non-canonical rows, rebuild # the index transactionally, and prove the conflict-update path before # rolling out the scanner. - name: Repair Asset Inventory Canonical Integrity env: DATABASE_URL: ${{ secrets.DATABASE_URL }} MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }} run: | set -euo pipefail if [ -z "${MIGRATION_DATABASE_URL:-}" ]; then echo "::error::MIGRATION_DATABASE_URL secret not set in Gitea" exit 1 fi docker run --rm --network bridge -i \ -e DATABASE_URL \ -e MIGRATION_DATABASE_URL \ -v "$PWD/apps/api/migrations/asset_inventory_canonical_integrity_2026-07-14.sql:/tmp/asset_inventory_integrity.sql:ro" \ "${HARBOR}/awoooi/api:${{ github.sha }}" python - <<'PY' import asyncio import json import os from pathlib import Path import asyncpg MIGRATION_NAME = "asset_inventory_canonical_integrity_2026-07-14" MIGRATION = Path("/tmp/asset_inventory_integrity.sql").read_text( encoding="utf-8" ) def normalize_url(value: str) -> str: return value.replace("postgresql+asyncpg://", "postgresql://", 1) def receipt_valid(receipt: dict[str, object] | None) -> bool: return bool( receipt and receipt.get("duplicate_group_count_after") == 0 and receipt.get("index_rebuilt") is True and receipt.get("canonical_upsert_verified") is True and receipt.get("data_rows_deleted") == 0 ) async def integrity_status( connection: asyncpg.Connection, ) -> tuple[int, int, bool, dict[str, object] | None]: row = await connection.fetchrow( """ WITH duplicate_groups AS ( SELECT COUNT(*) AS count FROM ( SELECT asset_key FROM asset_inventory GROUP BY asset_key HAVING COUNT(*) > 1 ) AS grouped_duplicates ), latest_receipt AS ( SELECT output::text AS output FROM automation_operation_log WHERE actor = 'gitea_cd_asset_integrity_reconciler' AND operation_type = 'asset_discovered' AND status = 'success' AND input ->> 'migration' = $1 ORDER BY created_at DESC LIMIT 1 ) SELECT duplicate_groups.count AS duplicate_group_count, ( SELECT COUNT(*) FROM asset_inventory WHERE lifecycle_state = 'deprecated' AND metadata ->> 'integrity_state' = 'quarantined_duplicate' ) AS quarantined_row_count, COALESCE(( SELECT index_row.indisvalid AND index_row.indisready AND index_row.indislive AND index_row.indisunique FROM pg_index AS index_row WHERE index_row.indexrelid = 'asset_inventory_asset_key_key'::regclass ), FALSE) AS unique_index_healthy, (SELECT output FROM latest_receipt) AS receipt_output FROM duplicate_groups """, MIGRATION_NAME, ) if row is None: raise RuntimeError("asset_inventory_integrity_status_missing") receipt_text = row["receipt_output"] receipt = json.loads(receipt_text) if receipt_text else None return ( int(row["duplicate_group_count"] or 0), int(row["quarantined_row_count"] or 0), bool(row["unique_index_healthy"]), receipt, ) async def reconcile( url: str, ) -> tuple[int, int, bool, dict[str, object] | None, bool]: connection = await asyncpg.connect(normalize_url(url), timeout=10) applied = False try: # Do not let a corrupt-but-valid unique index hide duplicate # heap rows from either the preflight or post-verifier. await connection.execute("SET enable_indexscan TO off") await connection.execute("SET enable_indexonlyscan TO off") await connection.execute("SET enable_bitmapscan TO off") duplicate_groups, _, index_healthy, receipt = ( await integrity_status(connection) ) if ( duplicate_groups > 0 or not index_healthy or not receipt_valid(receipt) ): async with connection.transaction(): await connection.execute(MIGRATION) applied = True status = await integrity_status(connection) return (*status, applied) finally: await connection.close() async def main() -> None: candidates = [ ("migration_role", os.environ["MIGRATION_DATABASE_URL"]), ("owner_role", os.environ.get("DATABASE_URL", "")), ] for index, (role, url) in enumerate(candidates): if not url: continue try: ( duplicate_groups, quarantined_rows, index_healthy, receipt, applied, ) = await reconcile(url) verified = ( duplicate_groups == 0 and index_healthy and receipt_valid(receipt) ) print(f"asset_inventory_integrity_migration_role={role}") print( "asset_inventory_integrity_migration_applied=" f"{str(applied).lower()}" ) print(f"duplicate_group_count={duplicate_groups}") print(f"quarantined_row_count={quarantined_rows}") print(f"unique_index_healthy={str(index_healthy).lower()}") print( "durable_integrity_receipt_present=" f"{str(receipt is not None).lower()}" ) print(f"canonical_integrity_verified={str(verified).lower()}") if not verified: raise RuntimeError( "asset_inventory_canonical_integrity_verifier_failed" ) return except asyncpg.PostgresError as exc: permission_denied = getattr(exc, "sqlstate", "") == "42501" has_owner_fallback = index == 0 and bool(candidates[1][1]) if permission_denied and has_owner_fallback: print("asset_inventory_integrity_owner_fallback=true") continue print( "asset_inventory_integrity_error_class=" f"{type(exc).__name__}" ) raise raise RuntimeError( "asset_inventory_integrity_migration_no_usable_connection" ) asyncio.run(main()) PY # 2026-03-31 ogt: 移除中間通知 # 2026-03-31 ogt: P0-1 Secrets 自動注入 (ADR-035 強制) # 2026-03-31 ogt: 加入 AI API Keys (修復 mock_fallback 問題) - name: Inject K8s Secrets env: ARGOCD_API_TOKEN: ${{ secrets.ARGOCD_API_TOKEN }} AWOOOI_GITEA_API_TOKEN: ${{ secrets.AWOOOI_GITEA_API_TOKEN }} AWOOOI_GITEA_WEBHOOK_SECRET: ${{ secrets.AWOOOI_GITEA_WEBHOOK_SECRET }} AWOOOP_OPERATOR_API_KEY: ${{ secrets.AWOOOP_OPERATOR_API_KEY }} CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} DATABASE_URL: ${{ secrets.DATABASE_URL }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} JWT_ALGORITHM: ${{ secrets.JWT_ALGORITHM }} JWT_SECRET: ${{ secrets.JWT_SECRET }} LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }} LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }} MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }} NEMOTRON_BOT_TOKEN: ${{ secrets.NEMOTRON_BOT_TOKEN }} NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} OPENCLAW_BOT_TOKEN: ${{ secrets.OPENCLAW_BOT_TOKEN }} OPENCLAW_TG_USER_WHITELIST: ${{ secrets.OPENCLAW_TG_USER_WHITELIST }} REDIS_URL: ${{ secrets.REDIS_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SMTP_HOST: ${{ secrets.SMTP_HOST }} SRE_GROUP_CHAT_ID: ${{ secrets.SRE_GROUP_CHAT_ID }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} WEBHOOK_HMAC_SECRET: ${{ secrets.WEBHOOK_HMAC_SECRET }} run: | # 2026-06-30 Codex: do not inline action secret expressions in run scripts. # Gitea renders the shell body into job logs before masking. Keep # secret values in process env, then read by variable name only. secret_b64_env() { local env_name="$1" if command -v python3.11 >/dev/null 2>&1; then SECRET_ENV_NAME="${env_name}" python3.11 - <<'PY' import base64 import os data = os.environ.get(os.environ["SECRET_ENV_NAME"], "").encode() data = data[:-1] if data.endswith(b"\n") else data print(base64.b64encode(data).decode(), end="") PY elif command -v python3 >/dev/null 2>&1; then SECRET_ENV_NAME="${env_name}" python3 - <<'PY' import base64 import os data = os.environ.get(os.environ["SECRET_ENV_NAME"], "").encode() data = data[:-1] if data.endswith(b"\n") else data print(base64.b64encode(data).decode(), end="") PY else secret_value="$(printenv "${env_name}" || true)" printf '%s' "${secret_value}" | base64 | tr -d '\n' fi } prepare_deploy_key() { mkdir -p "${HOME}/.ssh" umask 077 local source_key="${AWOOOI_DEPLOY_SSH_KEY_PATH:-${HOME}/.ssh/deploy_key}" if [ ! -r "${source_key}" ]; then echo "❌ deploy ssh key file missing: ${source_key}" >&2 exit 1 fi if [ "${source_key}" != "${HOME}/.ssh/deploy_key" ]; then cp "${source_key}" "${HOME}/.ssh/deploy_key" fi chmod 600 "${HOME}/.ssh/deploy_key" } TG_BOT_TOKEN_B64="$(secret_b64_env TELEGRAM_BOT_TOKEN)" TG_CHAT_ID_B64="$(secret_b64_env SRE_GROUP_CHAT_ID)" NVIDIA_API_KEY_B64="$(secret_b64_env NVIDIA_API_KEY)" GEMINI_API_KEY_B64="$(secret_b64_env GEMINI_API_KEY)" LANGFUSE_PUBLIC_KEY_B64="$(secret_b64_env LANGFUSE_PUBLIC_KEY)" LANGFUSE_SECRET_KEY_B64="$(secret_b64_env LANGFUSE_SECRET_KEY)" TG_USER_WHITELIST_B64="$(secret_b64_env OPENCLAW_TG_USER_WHITELIST)" SENTRY_AUTH_TOKEN_B64="$(secret_b64_env SENTRY_AUTH_TOKEN)" GITEA_WEBHOOK_SECRET_B64="$(secret_b64_env AWOOOI_GITEA_WEBHOOK_SECRET)" ARGOCD_API_TOKEN_B64="$(secret_b64_env ARGOCD_API_TOKEN)" DATABASE_URL_B64="$(secret_b64_env DATABASE_URL)" MIGRATION_DATABASE_URL_B64="$(secret_b64_env MIGRATION_DATABASE_URL)" REDIS_URL_B64="$(secret_b64_env REDIS_URL)" JWT_SECRET_B64="$(secret_b64_env JWT_SECRET)" JWT_ALGORITHM_B64="$(secret_b64_env JWT_ALGORITHM)" WEBHOOK_HMAC_SECRET_B64="$(secret_b64_env WEBHOOK_HMAC_SECRET)" AWOOOP_OPERATOR_API_KEY_B64="$(secret_b64_env AWOOOP_OPERATOR_API_KEY)" SENTRY_DSN_B64="$(secret_b64_env SENTRY_DSN)" CLAUDE_API_KEY_B64="$(secret_b64_env CLAUDE_API_KEY)" GITEA_API_TOKEN_B64="$(secret_b64_env AWOOOI_GITEA_API_TOKEN)" NEMOTRON_BOT_TOKEN_B64="$(secret_b64_env NEMOTRON_BOT_TOKEN)" OPENCLAW_BOT_TOKEN_B64="$(secret_b64_env OPENCLAW_BOT_TOKEN)" SMTP_HOST_B64="$(secret_b64_env SMTP_HOST)" SRE_GROUP_CHAT_ID_B64="$(secret_b64_env SRE_GROUP_CHAT_ID)" # S1/S2: 統一命名 deploy_key,改用 ssh-keyscan 與強制 host key 驗證。 prepare_deploy_key # 2026-05-13 Codex: keyscan must include ED25519 explicitly. Some # OpenSSH builds otherwise record only RSA/ECDSA, then strict deploy # SSH fails with "No ED25519 host key is known" after image push. # 2026-06-13 Codex: keep deploy-time host keys in a dedicated file. # The runner user's global known_hosts is shared by cold-start and # backup checks for 120/188; overwriting it here caused strict SSH # recovery gates to flap after every CD run. DEPLOY_KNOWN_HOSTS="${HOME}/.ssh/deploy_known_hosts" ssh-keyscan -T 5 -t ed25519,rsa,ecdsa "${K8S_SSH_HOST}" > "${DEPLOY_KNOWN_HOSTS}" 2>/dev/null test -s "${DEPLOY_KNOWN_HOSTS}" || { echo "❌ K8S host keyscan failed: ${K8S_SSH_HOST}"; exit 1; } SSH_OPTS="-i ${HOME}/.ssh/deploy_key -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${DEPLOY_KNOWN_HOSTS} -o ConnectTimeout=10" ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" << SECRETS set -e K8S_API_SERVER="${{ env.K8S_API_SERVER }}" KUBECTL="sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=\${K8S_API_SERVER}" # 注入 Telegram Secrets (ADR-035 鐵律) # 2026-06-12 Codex: OPENCLAW_TG_CHAT_ID 僅作舊欄位相容, # 實際值必須與 SRE_GROUP_CHAT_ID 一致,避免正式告警旁路到其他群組。 \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/OPENCLAW_TG_BOT_TOKEN","value":"${TG_BOT_TOKEN_B64}"}, {"op":"add","path":"/data/OPENCLAW_TG_CHAT_ID","value":"${TG_CHAT_ID_B64}"} ]' || { echo "❌ Telegram Secrets patch 失敗 — ADR-035 鐵律"; exit 1; } # 2026-03-31 ogt: 注入 AI API Keys (修復 NVIDIA/Gemini mock_fallback) # 2026-04-01 Claude Code: base64 -w 0 防止長 key 換行破壞 JSON # NVIDIA NIM (免費 tier) if [ -n "${NVIDIA_API_KEY_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/NVIDIA_API_KEY","value":"${NVIDIA_API_KEY_B64}"} ]' && echo "✅ NVIDIA_API_KEY 已注入" || echo "⚠️ NVIDIA_API_KEY patch 失敗" else echo "⚠️ NVIDIA_API_KEY 未設定,跳過" fi # Gemini (備援) if [ -n "${GEMINI_API_KEY_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/GEMINI_API_KEY","value":"${GEMINI_API_KEY_B64}"} ]' && echo "✅ GEMINI_API_KEY 已注入" || echo "⚠️ GEMINI_API_KEY patch 失敗" else echo "⚠️ GEMINI_API_KEY 未設定,跳過" fi # 2026-04-01 Claude Code: Langfuse LLMOps keys (補齊 CD 注入,之前只有手動設定) if [ -n "${LANGFUSE_PUBLIC_KEY_B64}" ] && [ -n "${LANGFUSE_SECRET_KEY_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/LANGFUSE_PUBLIC_KEY","value":"${LANGFUSE_PUBLIC_KEY_B64}"}, {"op":"add","path":"/data/LANGFUSE_SECRET_KEY","value":"${LANGFUSE_SECRET_KEY_B64}"} ]' && echo "✅ LANGFUSE keys 已注入" || echo "⚠️ LANGFUSE keys patch 失敗" else echo "⚠️ LANGFUSE_PUBLIC_KEY/SECRET_KEY 未設定,跳過 (現有 K8s secret 值維持不變)" fi # 2026-04-02 Claude Code: Telegram Whitelist (授權簽核用戶 ID) if [ -n "${TG_USER_WHITELIST_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/OPENCLAW_TG_USER_WHITELIST","value":"${TG_USER_WHITELIST_B64}"} ]' && echo "✅ TG_USER_WHITELIST 已注入" || echo "⚠️ TG_USER_WHITELIST patch 失敗" fi # Phase O-4.1 2026-04-02: Sentry Auth Token (Wave A.1 ADR-037) if [ -n "${SENTRY_AUTH_TOKEN_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/SENTRY_AUTH_TOKEN","value":"${SENTRY_AUTH_TOKEN_B64}"} ]' && echo "✅ SENTRY_AUTH_TOKEN 已注入" || echo "⚠️ SENTRY_AUTH_TOKEN patch 失敗" else echo "⚠️ SENTRY_AUTH_TOKEN 未設定,Sentry Comment API 將跳過" fi # ADR-059 2026-04-05 Claude Code: Gitea Webhook Secret if [ -n "${GITEA_WEBHOOK_SECRET_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/GITEA_WEBHOOK_SECRET","value":"${GITEA_WEBHOOK_SECRET_B64}"} ]' && echo "✅ GITEA_WEBHOOK_SECRET 已注入" || echo "⚠️ GITEA_WEBHOOK_SECRET patch 失敗" else echo "⚠️ GITEA_WEBHOOK_SECRET 未設定,Gitea Webhook 簽章驗證將在 prod 失效" fi # MCP Phase 3: ArgoCD API Token (2026-04-11 Claude Sonnet 4.6) if [ -n "${ARGOCD_API_TOKEN_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/ARGOCD_API_TOKEN","value":"${ARGOCD_API_TOKEN_B64}"} ]' && echo "✅ ARGOCD_API_TOKEN 已注入" || echo "⚠️ ARGOCD_API_TOKEN patch 失敗" else echo "⚠️ ARGOCD_API_TOKEN 未設定,ArgoCD MCP 將使用空 token" fi # ============================================================================ # ADR-090-B 2026-04-18 ogt + Claude Opus 4.7: L3-only 升級 L2(13 個 key) # ============================================================================ # 目的: 消滅「只存 K8s etcd 單點」的災難盲區,Gitea Secret 成為正式真相來源 # 注意: 每個 block 與上方維持相同結構(if guard + base64 -w 0 + json patch) # DATABASE_URL — PG 應用連線串(2026-04-18 輪替) if [ -n "${DATABASE_URL_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/DATABASE_URL","value":"${DATABASE_URL_B64}"} ]' && echo "✅ DATABASE_URL 已注入" || echo "⚠️ DATABASE_URL patch 失敗" else echo "⚠️ DATABASE_URL 未設定,awoooi-api 將無法連 PG" fi # MIGRATION_DATABASE_URL — CI migration 用 awoooi_migrator 限權帳號(ADR-090-B) if [ -n "${MIGRATION_DATABASE_URL_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/MIGRATION_DATABASE_URL","value":"${MIGRATION_DATABASE_URL_B64}"} ]' && echo "✅ MIGRATION_DATABASE_URL 已注入" || echo "⚠️ MIGRATION_DATABASE_URL patch 失敗" fi # REDIS_URL — Redis 連線(6380 on 188) if [ -n "${REDIS_URL_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/REDIS_URL","value":"${REDIS_URL_B64}"} ]' && echo "✅ REDIS_URL 已注入" || echo "⚠️ REDIS_URL patch 失敗" else echo "⚠️ REDIS_URL 未設定" fi # JWT_SECRET / JWT_ALGORITHM — API 認證 if [ -n "${JWT_SECRET_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/JWT_SECRET","value":"${JWT_SECRET_B64}"} ]' && echo "✅ JWT_SECRET 已注入" || echo "⚠️ JWT_SECRET patch 失敗" fi if [ -n "${JWT_ALGORITHM_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/JWT_ALGORITHM","value":"${JWT_ALGORITHM_B64}"} ]' && echo "✅ JWT_ALGORITHM 已注入" || echo "⚠️ JWT_ALGORITHM patch 失敗" fi # WEBHOOK_HMAC_SECRET — Alertmanager webhook HMAC 簽章 if [ -n "${WEBHOOK_HMAC_SECRET_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/WEBHOOK_HMAC_SECRET","value":"${WEBHOOK_HMAC_SECRET_B64}"} ]' && echo "✅ WEBHOOK_HMAC_SECRET 已注入" || echo "⚠️ WEBHOOK_HMAC_SECRET patch 失敗" fi # AWOOOP_OPERATOR_API_KEY — AwoooP Operator mutation endpoints if [ -n "${AWOOOP_OPERATOR_API_KEY_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/AWOOOP_OPERATOR_API_KEY","value":"${AWOOOP_OPERATOR_API_KEY_B64}"} ]' && echo "✅ AWOOOP_OPERATOR_API_KEY 已注入" || echo "⚠️ AWOOOP_OPERATOR_API_KEY patch 失敗" fi # SENTRY_DSN — Sentry 錯誤追蹤(不是 auth token) if [ -n "${SENTRY_DSN_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/SENTRY_DSN","value":"${SENTRY_DSN_B64}"} ]' && echo "✅ SENTRY_DSN 已注入" || echo "⚠️ SENTRY_DSN patch 失敗" fi # CLAUDE_API_KEY — Claude 備援 LLM if [ -n "${CLAUDE_API_KEY_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/CLAUDE_API_KEY","value":"${CLAUDE_API_KEY_B64}"} ]' && echo "✅ CLAUDE_API_KEY 已注入" || echo "⚠️ CLAUDE_API_KEY patch 失敗" fi # GITEA_API_TOKEN — Gitea API Token(從 AWOOOI_GITEA_API_TOKEN 映射) if [ -n "${GITEA_API_TOKEN_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/GITEA_API_TOKEN","value":"${GITEA_API_TOKEN_B64}"} ]' && echo "✅ GITEA_API_TOKEN 已注入" || echo "⚠️ GITEA_API_TOKEN patch 失敗" fi # NEMOTRON_BOT_TOKEN / OPENCLAW_BOT_TOKEN — 多 Bot 架構 if [ -n "${NEMOTRON_BOT_TOKEN_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/NEMOTRON_BOT_TOKEN","value":"${NEMOTRON_BOT_TOKEN_B64}"} ]' && echo "✅ NEMOTRON_BOT_TOKEN 已注入" || echo "⚠️ NEMOTRON_BOT_TOKEN patch 失敗" fi if [ -n "${OPENCLAW_BOT_TOKEN_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/OPENCLAW_BOT_TOKEN","value":"${OPENCLAW_BOT_TOKEN_B64}"} ]' && echo "✅ OPENCLAW_BOT_TOKEN 已注入" || echo "⚠️ OPENCLAW_BOT_TOKEN patch 失敗" fi # SMTP_HOST / SRE_GROUP_CHAT_ID if [ -n "${SMTP_HOST_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/SMTP_HOST","value":"${SMTP_HOST_B64}"} ]' && echo "✅ SMTP_HOST 已注入" || echo "⚠️ SMTP_HOST patch 失敗" fi if [ -n "${SRE_GROUP_CHAT_ID_B64}" ]; then \$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ {"op":"add","path":"/data/SRE_GROUP_CHAT_ID","value":"${SRE_GROUP_CHAT_ID_B64}"} ]' && echo "✅ SRE_GROUP_CHAT_ID 已注入" || echo "⚠️ SRE_GROUP_CHAT_ID patch 失敗" fi # 2026-04-06 Claude Code: Sprint 3 T2 — known_hosts Secret (Security Fix A1) # 替換關閉 host key 驗證的舊做法,讓 SSH 修復路徑使用已知主機指紋。 # asyncssh reads /etc/ssh-mcp/known_hosts and requires a non-empty # OpenSSH known_hosts file. Keep hosts unhashed so both asyncssh and # CLI diagnostics can trust the same secret. # 2026-05-02 ogt + Claude Sonnet 4.6: 加主機完整性檢查 # 根因:partial scan(如 110 timeout、其他成功)會讓 [-s file] 通過、 # 後續 patch 推進缺漏的 known_hosts → asyncssh 拒所有 SSH。 # 修法:scan 完驗證所有 allowlisted 主機都在;缺任何一台就 abort, # 不能覆蓋現有 secret,防止 production SSH 自動修復路徑癱瘓。 : > /tmp/known_hosts_repair : > /tmp/known_hosts_scan_err EXPECTED_HOSTS=5 PRESENT=0 for scan_attempt in 1 2 3; do ssh-keyscan -T 5 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 \ >> /tmp/known_hosts_repair 2>>/tmp/known_hosts_scan_err || true sort -u /tmp/known_hosts_repair -o /tmp/known_hosts_repair PRESENT=0 for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then PRESENT=\$((PRESENT + 1)) fi done if [ "\$PRESENT" -eq "\$EXPECTED_HOSTS" ]; then break fi sleep "\$scan_attempt" done for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then : else echo "⚠️ ssh-keyscan 缺主機 \${ip}" fi done if [ "\$PRESENT" -eq "\$EXPECTED_HOSTS" ]; then \$KUBECTL create secret generic awoooi-repair-known-hosts \ -n awoooi-prod \ --from-file=known_hosts=/tmp/known_hosts_repair \ --dry-run=client -o yaml | \$KUBECTL apply -f - \ && echo "✅ awoooi-repair-known-hosts Secret 已建立/更新" \ || echo "⚠️ awoooi-repair-known-hosts Secret 建立失敗 (非致命)" KNOWN_HOSTS_B64=\$(base64 -w 0 /tmp/known_hosts_repair) \$KUBECTL patch secret ssh-mcp-key -n awoooi-prod --type=merge \ -p="{\"data\":{\"known_hosts\":\"\${KNOWN_HOSTS_B64}\"}}" \ && echo "✅ ssh-mcp-key known_hosts 已更新(5 台主機完整)" \ || { echo "❌ ssh-mcp-key known_hosts 更新失敗,停止部署"; exit 1; } rm -f /tmp/known_hosts_repair /tmp/known_hosts_scan_err else # A transient guest SSH outage must not overwrite trust data, but it # also must not blindly green-light the deploy. Decode only on the # K3s controller, verify all five public host entries, then delete # the temporary file without printing its contents. EXISTING_KNOWN_HOSTS=/tmp/known_hosts_existing EXISTING_PRESENT=0 if \$KUBECTL get secret ssh-mcp-key -n awoooi-prod \ -o jsonpath='{.data.known_hosts}' | base64 -d > "\$EXISTING_KNOWN_HOSTS"; then for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do if grep -qE "^\${ip}[[:space:]]" "\$EXISTING_KNOWN_HOSTS" 2>/dev/null; then EXISTING_PRESENT=\$((EXISTING_PRESENT + 1)) fi done fi rm -f "\$EXISTING_KNOWN_HOSTS" /tmp/known_hosts_repair /tmp/known_hosts_scan_err if [ "\$EXISTING_PRESENT" -eq "\$EXPECTED_HOSTS" ]; then echo "⚠️ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key verified 5/5 and preserved" else echo "❌ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key coverage \${EXISTING_PRESENT}/\${EXPECTED_HOSTS}" exit 1 fi fi echo "✅ 所有 Secrets 注入完成" SECRETS # AIA-P0-011: record only Secret object pre-existence, never Secret data. # The bootstrap streams CSPRNG credentials directly to PostgreSQL and # kubectl stdin, then proves each identity with a no-write preflight. WORKLOAD_DB_SECRET_PREEXISTENCE_FILE="/tmp/awoooi-workload-db-secret-preexistence" : > "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE" for secret_name in awoooi-api-db awoooi-worker-db awoooi-broker-db; do secret_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 secret '${secret_name}' -n awoooi-prod >/dev/null 2>&1; then printf 1; else printf 0; fi") printf '%s=%s\n' "$secret_name" "$secret_existed" \ >> "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE" done cat scripts/ops/awoooi-workload-db-identity-bootstrap.sh | \ ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "AWOOOI_KUBECTL_SUDO=true AWOOOI_KUBECONFIG=/etc/rancher/k3s/k3s.yaml AWOOOI_KUBERNETES_SERVER='${{ env.K8S_API_SERVER }}' AWOOOI_RUN_ID='${GITEA_RUN_NUMBER:-cd}' bash -s -- apply" echo "✅ workload DB identities, Secret projections, and preflights ready" # 2026-04-11 Claude Sonnet 4.6 (Sprint B-3 ADR-069): # Deploy 改為 ArgoCD GitOps 模式:更新 kustomization.yaml → git push [skip ci] → ArgoCD sync # 舊做法 (kubectl set image) 與 ArgoCD selfHeal 衝突 — ArgoCD 會 revert 任何直接 kubectl 操作 # 新做法流程: # 1. 更新 kustomization.yaml image tag(用 kustomize edit set image) # 2. Apply ConfigMap/ServiceRegistry(不含 Deployment,由 ArgoCD 管) # 3. git commit [skip ci] + push → 觸發 ArgoCD automated sync # 4. 等待 ArgoCD sync + rollout 完成 # 5. Health Check - name: Deploy to K8s (ArgoCD GitOps) env: CD_PUSH_TOKEN: ${{ secrets.CD_PUSH_TOKEN }} run: | prepare_deploy_key() { mkdir -p "${HOME}/.ssh" umask 077 local source_key="${AWOOOI_DEPLOY_SSH_KEY_PATH:-${HOME}/.ssh/deploy_key}" if [ ! -r "${source_key}" ]; then echo "❌ deploy ssh key file missing: ${source_key}" >&2 exit 1 fi if [ "${source_key}" != "${HOME}/.ssh/deploy_key" ]; then cp "${source_key}" "${HOME}/.ssh/deploy_key" fi chmod 600 "${HOME}/.ssh/deploy_key" } mkdir -p ~/.ssh prepare_deploy_key # 2026-05-13 Codex: mirror Inject K8s Secrets host-key handling so the # deploy job never reaches SSH with a known_hosts file missing ED25519. # 2026-06-13 Codex: use the deploy-only known_hosts file so this # stage cannot wipe cold-start/backup host trust for 120/188. DEPLOY_KNOWN_HOSTS="${HOME}/.ssh/deploy_known_hosts" ssh-keyscan -T 5 -t ed25519,rsa,ecdsa "${K8S_SSH_HOST}" > "${DEPLOY_KNOWN_HOSTS}" 2>/dev/null test -s "${DEPLOY_KNOWN_HOSTS}" || { echo "❌ K8S host keyscan failed: ${K8S_SSH_HOST}"; exit 1; } SSH_OPTS="-i ${HOME}/.ssh/deploy_key -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${DEPLOY_KNOWN_HOSTS} -o ConnectTimeout=10" export IMAGE_TAG="${{ github.sha }}" HARBOR=192.168.0.110:5000 if ! printf '%s\n' "${API_IMAGE_DIGEST:-}" | \ grep -Eq '^sha256:[0-9a-f]{64}$'; then echo "BLOCKER api_harbor_digest_missing" exit 1 fi if ! printf '%s\n' "${WEB_IMAGE_DIGEST:-}" | \ grep -Eq '^sha256:[0-9a-f]{64}$'; then echo "BLOCKER web_harbor_digest_missing" exit 1 fi # ─── Step 1: Apply NetworkPolicy + ConfigMap + ServiceRegistry ─── # Preserve only live Deployment specs and Secret object existence. # Secret data is never read or copied into the rollback artifact. WORKLOAD_DB_ROLLBACK_FILE="/tmp/awoooi-workload-db-deployment-rollback.yaml" WORKLOAD_DB_SECRET_PREEXISTENCE_FILE="/tmp/awoooi-workload-db-secret-preexistence" ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL get deployment awoooi-api awoooi-worker awoooi-ansible-executor-broker -n awoooi-prod -o json" \ | python3 -c ' import json import sys source = json.load(sys.stdin) items = [ { "apiVersion": "apps/v1", "kind": "Deployment", "metadata": { "name": item["metadata"]["name"], "namespace": item["metadata"]["namespace"], "labels": item["metadata"].get("labels", {}), }, "spec": item["spec"], } for item in source["items"] ] json.dump({"apiVersion": "v1", "kind": "List", "items": items}, sys.stdout) ' > "$WORKLOAD_DB_ROLLBACK_FILE" test -s "$WORKLOAD_DB_ROLLBACK_FILE" if [ ! -s "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE" ]; then : > "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE" for secret_name in awoooi-api-db awoooi-worker-db awoooi-broker-db; do secret_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 secret '${secret_name}' -n awoooi-prod >/dev/null 2>&1; then printf 1; else printf 0; fi") printf '%s=%s\n' "$secret_name" "$secret_existed" \ >> "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE" done fi restore_workload_database_projection() { echo "⏪ restoring previous AWOOOI workload database projection" cat "$WORKLOAD_DB_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 -" for deployment_name in awoooi-api awoooi-worker awoooi-ansible-executor-broker; do ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL rollout status deployment/${deployment_name} -n awoooi-prod --timeout=180s" done disable_workloads=() while IFS='=' read -r secret_name secret_existed; do [ "$secret_existed" = "0" ] || continue case "$secret_name" in awoooi-api-db) disable_workloads+=(api) ;; awoooi-worker-db) disable_workloads+=(worker) ;; awoooi-broker-db) disable_workloads+=(broker) ;; esac ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL delete secret '${secret_name}' -n awoooi-prod --ignore-not-found=true" done < "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE" if [ "${#disable_workloads[@]}" -gt 0 ]; then cat scripts/ops/awoooi-workload-db-identity-bootstrap.sh | \ ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "AWOOOI_KUBECTL_SUDO=true AWOOOI_KUBECONFIG=/etc/rancher/k3s/k3s.yaml AWOOOI_KUBERNETES_SERVER='${{ env.K8S_API_SERVER }}' bash -s -- disable ${disable_workloads[*]}" fi } # NetworkPolicy is intentionally outside kustomize because commonLabels # 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_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") NETWORK_POLICY_NAMES=( default-deny-all allow-nginx-ingress allow-required-egress deny-legacy-access ) if [ "$BROKER_POLICY_EXISTED" = "1" ]; then NETWORK_POLICY_NAMES+=("$BROKER_POLICY_NAME") 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 ${NETWORK_POLICY_NAMES[*]} -n awoooi-prod -o json" \ | python3 -c ' import json import sys source = json.load(sys.stdin) items = [ { "apiVersion": item["apiVersion"], "kind": item["kind"], "metadata": { "name": item["metadata"]["name"], "namespace": item["metadata"]["namespace"], }, "spec": item["spec"], } for item in source["items"] ] json.dump({"apiVersion": "v1", "kind": "List", "items": items}, sys.stdout) ' \ > "$NETWORK_POLICY_ROLLBACK_FILE" test -s "$NETWORK_POLICY_ROLLBACK_FILE" restore_network_policy() { echo "⏪ restoring previous AWOOOI NetworkPolicy contract" 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 restore_status="0" else restore_status="$?" fi 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" } restore_deploy_controls() { set +e restore_workload_database_projection workload_restore_exit=$? restore_network_policy network_restore_exit=$? set -e if [ "$workload_restore_exit" -ne 0 ] \ || [ "$network_restore_exit" -ne 0 ]; then echo "❌ deploy rollback incomplete: workload=${workload_restore_exit} network=${network_restore_exit}" >&2 return 1 fi } rollback_deploy_controls_on_exit() { exit_code=$? trap - EXIT if [ "$exit_code" -ne 0 ] \ && [ "$NETWORK_POLICY_CHANGE_ACTIVE" = "1" ]; then echo "⏪ deploy exited before the terminal boundary verifier" set +e restore_deploy_controls restore_exit=$? set -e if [ "$restore_exit" -ne 0 ]; then echo "❌ deploy control rollback failed" fi fi exit "$exit_code" } trap rollback_deploy_controls_on_exit EXIT cat k8s/awoooi-prod/02-network-policy.yaml | \ ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply --dry-run=server -f - >/dev/null" if ! cat k8s/awoooi-prod/02-network-policy.yaml | \ 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 restore_deploy_controls || true exit 1 fi NETWORK_POLICY_CHANGE_ACTIVE="1" echo "✅ NetworkPolicy 已受控更新" cat k8s/awoooi-prod/04-configmap.yaml | \ 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 -" echo "✅ ConfigMap 已更新" cat k8s/awoooi-prod/15-service-registry-configmap.yaml | \ 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 -" echo "✅ Service Registry ConfigMap 已更新" # ─── Step 2: 更新 kustomization.yaml immutable image digest ─── # kustomize is an immutable dependency of the internal runner image. # GitHub is frozen, so CD must never download tools from public GitHub. export PATH="${HOME}/.local/bin:${PATH}" command -v kustomize >/dev/null 2>&1 || { echo "❌ internal runner image is missing required kustomize" exit 1 } cd k8s/awoooi-prod # Source SHA remains in AWOOOI_BUILD_COMMIT_SHA; workload identity is # pinned to the independently read-back Harbor manifest digest. kustomize edit set image \ 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER=${HARBOR}/awoooi/api@${API_IMAGE_DIGEST} kustomize edit set image \ 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER=${HARBOR}/awoooi/web@${WEB_IMAGE_DIGEST} cd ../.. # 2026-06-29 Codex: API deploy readback uses AWOOOI_BUILD_COMMIT_SHA # to compare runtime image/source truth. Keep it in the same deploy # marker commit as kustomization.yaml so the production Workbench does # not depend on stale committed snapshots. python3 - <<'PY' import os import re from pathlib import Path image_tag = os.environ["IMAGE_TAG"] def replace_env_value(source: str, env_name: str) -> str: pattern = ( rf'(\n\s+- name: {re.escape(env_name)}\n' r'(?:\s+# [^\n]*\n)*' r'\s+value: ")[^"]*(")' ) if not re.search(pattern, source): raise SystemExit(f"{env_name} env block not found") return re.sub( pattern, lambda match: f"{match.group(1)}{image_tag}{match.group(2)}", source, count=1, ) manifests = { Path("k8s/awoooi-prod/06-deployment-api.yaml"): ( "AWOOOI_BUILD_COMMIT_SHA", "AWOOOI_DESIRED_API_IMAGE_TAG", ), Path("k8s/awoooi-prod/08-deployment-worker.yaml"): ( "AWOOOI_BUILD_COMMIT_SHA", ), Path( "k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml" ): ("AWOOOI_BUILD_COMMIT_SHA",), } for path, env_names in manifests.items(): text = path.read_text(encoding="utf-8") for env_name in env_names: text = replace_env_value(text, env_name) path.write_text(text, encoding="utf-8") PY # ─── Step 3: git commit [skip ci] + push → 觸發 ArgoCD sync ─── git config user.email "cd@awoooi.internal" git config user.name "AWOOOI CD" git add \ k8s/awoooi-prod/kustomization.yaml \ k8s/awoooi-prod/06-deployment-api.yaml \ k8s/awoooi-prod/08-deployment-worker.yaml \ k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml DEPLOY_REVISION="" git diff --cached --quiet && echo "⚡ kustomization.yaml 無變化,跳過 push" || { git commit -m "chore(cd): deploy ${IMAGE_TAG::7} [skip ci]" # 用 token 推送(避免 SSH key 需要額外設定 push 權限) git remote remove gitea 2>/dev/null || true git remote add gitea "http://wooo:${CD_PUSH_TOKEN}@192.168.0.110:3001/wooo/awoooi.git" # 先 rebase 避免 non-fast-forward (其他 commit 在 CI 期間已推入) # 2026-04-17 ogt: -X theirs — kustomization.yaml 衝突時採用當次部署的 image tag git fetch gitea main git rebase -X theirs gitea/main DEPLOY_REVISION=$(git rev-parse HEAD) git push gitea main echo "✅ kustomization.yaml 已 push,等待 ArgoCD sync 到 ${DEPLOY_REVISION:0:8}..." } # ─── Step 4: 等待 ArgoCD sync + rollout ─── ROLLOUT_LOG="$(mktemp)" set +e ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "EXPECTED_REVISION='${DEPLOY_REVISION}' SOURCE_REVISION='${IMAGE_TAG}' WORKFLOW_RUN_ID='${GITHUB_RUN_ID:-unknown}' bash -s" 2>&1 << 'ARGOCD_WAIT' | tee "$ROLLOUT_LOG" set -e K8S_API_SERVER="${{ env.K8S_API_SERVER }}" KUBECTL="sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${K8S_API_SERVER}" RISK_FILE="$(mktemp)" UNKNOWN_STATUS_COUNT=0 HEALTH_FAILURE_COUNT=0 DEPLOY_STAGE="argocd_sync" DEPLOY_TERMINAL_RECORDED=0 # The API probes permit 305 seconds for cold-start DB bootstrap plus # 20 seconds for readiness. Keep rollout above that bounded budget. ROLLOUT_TIMEOUT_SECONDS=360 record_rollout_risk() { local message="$1" printf '%s\n' "$message" >> "$RISK_FILE" echo "⚠️ Rollout risk observed: $message" >&2 } emit_deploy_terminal_receipt() { local status="$1" local stage="$2" local workload="$3" if [ "$DEPLOY_TERMINAL_RECORDED" = "1" ]; then return 0 fi printf '%s\n' \ "AWOOOI_DEPLOY_TERMINAL_RECEIPT=status=${status};stage=${stage};workload=${workload};source_revision=${SOURCE_REVISION};workflow_run_id=${WORKFLOW_RUN_ID};verifier_no_write=true;rollback_required=true" DEPLOY_TERMINAL_RECORDED=1 } emit_rollout_evidence() { local exit_code=$? if [ "$exit_code" -ne 0 ]; then emit_deploy_terminal_receipt \ "blocked_with_safe_next_action" "$DEPLOY_STAGE" "unknown" fi if [ -s "$RISK_FILE" ]; then local summary local kubectl_count kubectl_count=$(grep -c '^argocd_.*_query_failed=' "$RISK_FILE" 2>/dev/null || true) summary=$(tr '\n' '; ' < "$RISK_FILE" | sed 's/[[:cntrl:]]//g' | cut -c1-700) echo "AWOOOI_ROLLOUT_RISK=1" echo "AWOOOI_ROLLOUT_SUMMARY=unknown_status_count=${UNKNOWN_STATUS_COUNT}; health_failure_count=${HEALTH_FAILURE_COUNT}; kubectl_failure_count=${kubectl_count}; ${summary}" else echo "AWOOOI_ROLLOUT_RISK=0" fi rm -f "$RISK_FILE" return "$exit_code" } trap emit_rollout_evidence EXIT app_field() { local jsonpath="$1" local label="$2" local output local status local kubectl_seen set +e output=$($KUBECTL get application awoooi-prod -n argocd -o jsonpath="$jsonpath" 2>&1) status=$? set -e if [ "$status" -ne 0 ]; then kubectl_seen=$(grep -c '^argocd_.*_query_failed=' "$RISK_FILE" 2>/dev/null || true) if [ "$kubectl_seen" -lt 3 ]; then record_rollout_risk "argocd_${label}_query_failed=$(echo "$output" | head -c 180)" fi printf 'Unknown' return 0 fi printf '%s' "$output" } probe_public_health() { local phase="$1" local http_code local status set +e http_code=$(curl -sS -w "%{http_code}" -o /dev/null --connect-timeout 3 --max-time 8 "${{ env.API_HEALTH_URL }}" 2>/dev/null) status=$? set -e if [ "$status" -ne 0 ]; then http_code="curl_error_${status}" fi if [ "$http_code" != "200" ]; then HEALTH_FAILURE_COUNT=$((HEALTH_FAILURE_COUNT + 1)) if [ "$HEALTH_FAILURE_COUNT" -le 3 ]; then record_rollout_risk "public_health_${phase}_http=${http_code}" fi fi } collect_argocd_resource_evidence() { local template local output local status template='{{range .status.resources}}{{if ne .status "Synced"}}{{.kind}}/{{.name}}{{if .namespace}} ns={{.namespace}}{{end}} sync={{.status}}{{if .health.status}} health={{.health.status}}{{end}}{{"\n"}}{{end}}{{if .health.status}}{{if ne .health.status "Healthy"}}{{.kind}}/{{.name}}{{if .namespace}} ns={{.namespace}}{{end}} sync={{.status}} health={{.health.status}}{{if .health.message}} msg={{.health.message}}{{end}}{{"\n"}}{{end}}{{end}}{{end}}' set +e output=$($KUBECTL get application awoooi-prod -n argocd -o "go-template=${template}" 2>&1) status=$? set -e if [ "$status" -ne 0 ]; then local output_snippet output_snippet=$(printf '%s' "$output" | head -c 180) echo "resource_query_failed=${output_snippet}" return 0 fi echo "$output" \ | awk 'NF && !seen[$0]++ {print}' \ | head -5 \ | tr '\n' ';' \ | sed 's/[[:cntrl:]]//g; s/;*$//' } validate_argocd_source_contract() { local target_revision local image_override target_revision=$(app_field '{.spec.source.targetRevision}' source_target_revision) image_override=$(app_field '{.spec.source.kustomize.images}' source_kustomize_images) if [ "$target_revision" != "main" ]; then record_rollout_risk "argocd_source_target_revision_not_main targetRevision=$target_revision" echo "❌ ArgoCD source targetRevision must be main, got: $target_revision" >&2 exit 1 fi if [ -n "$image_override" ]; then local image_override_snippet image_override_snippet=$(printf '%s' "$image_override" | head -c 180) record_rollout_risk "argocd_source_image_override_present images=${image_override_snippet}" echo "❌ ArgoCD source kustomize.images override must be empty; image truth belongs in k8s/awoooi-prod/kustomization.yaml" >&2 exit 1 fi } # 等待 ArgoCD Application 同步到目標 revision(最多 180s)。 # 2026-05-24 Codex: top-level Application health can stay Degraded # without per-resource health detail. Treat that as rollout evidence, # then let kubectl rollout status and API health decide pass/fail. echo "⏳ 等待 ArgoCD sync..." validate_argocd_source_contract $KUBECTL annotate application awoooi-prod -n argocd \ argocd.argoproj.io/refresh=hard --overwrite >/dev/null 2>&1 || true for i in $(seq 1 36); do SYNC=$(app_field '{.status.sync.status}' sync) HEALTH=$(app_field '{.status.health.status}' health) REVISION=$(app_field '{.status.sync.revision}' revision) SHORT_REVISION=$(echo "$REVISION" | cut -c1-8) SHORT_EXPECTED=$(echo "$EXPECTED_REVISION" | cut -c1-8) echo " ArgoCD: sync=$SYNC health=$HEALTH revision=$SHORT_REVISION expected=${SHORT_EXPECTED:-any}" probe_public_health "argocd_wait" if [ "$SYNC" = "Unknown" ] || [ "$HEALTH" = "Unknown" ] || [ "$REVISION" = "Unknown" ]; then UNKNOWN_STATUS_COUNT=$((UNKNOWN_STATUS_COUNT + 1)) if [ "$UNKNOWN_STATUS_COUNT" -le 3 ]; then record_rollout_risk "argocd_status_unknown sync=$SYNC health=$HEALTH revision=$SHORT_REVISION expected=${SHORT_EXPECTED:-any}" fi fi if [ "$SYNC" = "Synced" ]; then if [ -z "$EXPECTED_REVISION" ] || [ "$REVISION" = "$EXPECTED_REVISION" ]; then if [ "$HEALTH" != "Healthy" ]; then RESOURCE_EVIDENCE=$(collect_argocd_resource_evidence) if [ -n "$RESOURCE_EVIDENCE" ]; then record_rollout_risk "argocd_health_not_healthy health=$HEALTH revision=$SHORT_REVISION resources=$RESOURCE_EVIDENCE" else record_rollout_risk "argocd_health_not_healthy health=$HEALTH revision=$SHORT_REVISION resources=none_visible" fi fi echo "✅ ArgoCD Synced to target revision (health=$HEALTH)" break fi fi if [ "$i" = "36" ]; then echo "❌ ArgoCD 未在期限內同步到目標 revision" exit 1 fi sleep 5 done wait_for_rollout() { local workload="$1" DEPLOY_STAGE="rollout_${workload}" if ! $KUBECTL rollout status "deployment/${workload}" \ -n awoooi-prod --timeout="${ROLLOUT_TIMEOUT_SECONDS}s"; then record_rollout_risk \ "deployment_rollout_timeout workload=${workload} timeout_seconds=${ROLLOUT_TIMEOUT_SECONDS}" emit_deploy_terminal_receipt \ "blocked_with_safe_next_action" "$DEPLOY_STAGE" "$workload" return 1 fi } # Two-replica rollouts can legitimately need more than two minutes. # Keep convergence bounded by the declared probe budget, and leave a # terminal receipt before the independent boundary verifier can run. wait_for_rollout awoooi-api || exit 1 wait_for_rollout awoooi-web || exit 1 wait_for_rollout awoooi-worker || exit 1 wait_for_rollout awoooi-ansible-executor-broker || exit 1 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. 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 } DEPLOY_STAGE="executor_boundary_collect_live_identity_and_policy" 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 \ -o jsonpath='{.spec.template.spec.serviceAccountName}') BROKER_SA=$($KUBECTL get deployment/awoooi-ansible-executor-broker -n awoooi-prod \ -o jsonpath='{.spec.template.spec.serviceAccountName}') WORKER_MOUNTS=$($KUBECTL get deployment/awoooi-worker -n awoooi-prod \ -o jsonpath='{range .spec.template.spec.containers[?(@.name=="worker")].volumeMounts[*]}{.name}{"\n"}{end}') WORKER_VOLUMES=$($KUBECTL get deployment/awoooi-worker -n awoooi-prod \ -o jsonpath='{range .spec.template.spec.volumes[*]}{.name}{"\n"}{end}') BROKER_AUTOMOUNT=$($KUBECTL get deployment/awoooi-ansible-executor-broker -n awoooi-prod \ -o jsonpath='{.spec.template.spec.automountServiceAccountToken}') BROKER_MOUNTS=$($KUBECTL get deployment/awoooi-ansible-executor-broker -n awoooi-prod \ -o jsonpath='{range .spec.template.spec.containers[?(@.name=="broker")].volumeMounts[*]}{.name}{"\n"}{end}') 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=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-api \ get pods --all-namespaces) API_CAN_PATCH=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-api \ patch deployments.apps --all-namespaces) API_CAN_DELETE=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-api \ delete pods --all-namespaces) WORKER_CAN_PATCH=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-worker \ patch deployments.apps --all-namespaces) WORKER_CAN_DELETE=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-worker \ delete pods --all-namespaces) BROKER_CAN_PATCH=$(auth_can_i \ --as=system:serviceaccount:awoooi-prod:awoooi-executor-broker \ patch deployments.apps --all-namespaces) 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 \ allow-required-egress -n awoooi-prod \ -o jsonpath='{range .spec.egress[*].ports[*]}{.port}{"\n"}{end}' \ | grep -c '^22$' || true) BROKER_SSH_SELECTOR=$($KUBECTL get networkpolicy \ allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ -o jsonpath='{.spec.podSelector.matchLabels.app}') BROKER_SSH_COMPONENT=$($KUBECTL get networkpolicy \ allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ -o jsonpath='{.spec.podSelector.matchLabels.component}') BROKER_SSH_PORTS=$($KUBECTL get networkpolicy \ allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ -o jsonpath='{range .spec.egress[*].ports[*]}{.port}{"\n"}{end}') BROKER_SSH_CIDRS=$($KUBECTL get networkpolicy \ allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ -o jsonpath='{range .spec.egress[*].to[*]}{.ipBlock.cidr}{"\n"}{end}') LEGACY_EGRESS_RULE_COUNT=$($KUBECTL get networkpolicy \ deny-legacy-access -n awoooi-prod -o json \ | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["spec"].get("egress") or []))') test "$API_SA" = "awoooi-api" || { echo "❌ API ServiceAccount drift: $API_SA"; exit 1; } test "$WORKER_SA" = "awoooi-worker" || { echo "❌ worker ServiceAccount drift: $WORKER_SA"; exit 1; } test "$BROKER_SA" = "awoooi-executor-broker" || { echo "❌ broker ServiceAccount drift: $BROKER_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; } if printf '%s\n%s\n' "$WORKER_MOUNTS" "$WORKER_VOLUMES" \ | grep -Eq '^(repair-ssh-key|repair-known-hosts|ssh-mcp-key)$'; then echo "❌ signal worker still mounts executor SSH credentials" exit 1 fi test "$BROKER_AUTOMOUNT" = "false" || { echo "❌ broker Kubernetes token automount is not disabled"; exit 1; } printf '%s\n' "$BROKER_MOUNTS" | grep -qx 'ssh-mcp-key' || { echo "❌ broker bounded SSH transport mount missing"; exit 1; } test "$WORKER_CAN_PATCH" = "no" || { echo "❌ signal worker can patch workloads"; exit 1; } test "$WORKER_CAN_DELETE" = "no" || { echo "❌ signal worker can delete pods"; exit 1; } test "$BROKER_CAN_PATCH" = "no" || { echo "❌ broker can patch Kubernetes workloads"; exit 1; } test "$BROKER_CAN_DELETE" = "no" || { echo "❌ broker can delete Kubernetes pods"; exit 1; } if $KUBECTL get clusterrolebinding awoooi-executor-binding >/dev/null 2>&1; then echo "❌ legacy cluster-wide executor binding still exists" exit 1 fi test "$COMMON_SSH_PORT_COUNT" = "0" || { echo "❌ common workload egress still grants SSH"; exit 1; } test "$BROKER_SSH_SELECTOR" = "awoooi-ansible-executor-broker" || { echo "❌ broker SSH policy selector drift: $BROKER_SSH_SELECTOR"; exit 1; } test "$BROKER_SSH_COMPONENT" = "execution-broker" || { echo "❌ broker SSH component selector drift: $BROKER_SSH_COMPONENT"; exit 1; } test "$BROKER_SSH_PORTS" = "22" || { echo "❌ broker SSH policy contains unexpected ports: $BROKER_SSH_PORTS"; exit 1; } for expected_cidr in \ 192.168.0.110/32 192.168.0.112/32 192.168.0.120/32 \ 192.168.0.121/32 192.168.0.188/32; do printf '%s\n' "$BROKER_SSH_CIDRS" | grep -qx "$expected_cidr" || { echo "❌ broker SSH allowlist missing $expected_cidr"; exit 1; } done test "$(printf '%s\n' "$BROKER_SSH_CIDRS" | sed '/^$/d' | wc -l)" = "5" || { echo "❌ broker SSH allowlist has unexpected destinations"; exit 1; } test "$LEGACY_EGRESS_RULE_COUNT" = "0" || { echo "❌ legacy namespace policy still creates an allow rule"; exit 1; } DEPLOY_STAGE="executor_boundary_static_identity_and_policy_verified" echo "executor_boundary_stage=static_identity_and_policy_verified" verify_ssh_denied() { workload="$1" container="$2" $KUBECTL exec -i "deployment/$workload" -n awoooi-prod \ -c "$container" -- python - \ 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' import socket import sys reachable = [] for host in sys.argv[1:]: try: connection = socket.create_connection((host, 22), timeout=0.75) except OSError: continue else: connection.close() reachable.append(host) print("unexpected_ssh_reachable=" + ",".join(reachable)) raise SystemExit(1 if reachable else 0) PY } verify_broker_ssh_allowed() { $KUBECTL exec -i deployment/awoooi-ansible-executor-broker \ -n awoooi-prod -c broker -- python - \ 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' import socket import sys connected = [] refused = [] unavailable = [] for host in sys.argv[1:]: try: connection = socket.create_connection((host, 22), timeout=1.0) except ConnectionRefusedError: # A TCP refusal proves that the packet crossed the egress # NetworkPolicy boundary and the target replied. Keep host # service readiness separate from this security-boundary # verifier; a stopped sshd must not make a healthy rollout # look like a policy failure. This is bounded to the fixed # targets whose exact CIDRs were verified immediately above. refused.append(host) except OSError: # Endpoint availability is a separate runtime signal. The # exact five-destination NetworkPolicy allowlist above is # the source of truth for the security boundary; one down # host must not roll back all healthy workloads. unavailable.append(host) else: connection.close() connected.append(host) print("broker_ssh_connected=" + ",".join(connected)) print( "broker_ssh_refused_but_egress_permitted=" + ",".join(refused) ) print( "broker_ssh_allowlisted_endpoint_unavailable=" + ",".join(unavailable) ) # Static verification already proves the exact five-destination # allowlist. The live probe only needs one positive route signal; # requiring every host to answer confuses host/service availability # (for example a booted host with sshd down) with NetworkPolicy # correctness. Arbitrary OSError values remain non-evidence, so an # all-timeout/unreachable result still fails closed. raise SystemExit(0 if connected or refused else 1) PY } verify_ssh_denied awoooi-api api || { echo "❌ API can still establish host SSH connections"; exit 1; } verify_ssh_denied awoooi-worker worker || { echo "❌ signal worker can still establish host SSH connections"; exit 1; } verify_broker_ssh_allowed || { echo "❌ execution broker has no live route evidence for allowlisted SSH endpoints"; exit 1; } DEPLOY_STAGE="executor_boundary_live_socket_boundary_verified" echo "executor_boundary_stage=live_socket_boundary_verified" read_workload_db_identity_once() { workload="$1" container="$2" representative_probe_count="$3" required_connection_budget="$4" last_failure="workload_not_ready" ready_pod_ref="" if ! $KUBECTL rollout status "deployment/$workload" \ -n awoooi-prod --timeout=20s >/dev/null 2>&1; then last_failure="deployment_rollout_not_ready" else ready_pod_ref=$( $KUBECTL get pods -n awoooi-prod -l "app=$workload" \ --field-selector=status.phase=Running \ --sort-by=.metadata.creationTimestamp \ -o name 2>/dev/null | tail -n 1 ) || ready_pod_ref="" if [ -z "$ready_pod_ref" ]; then last_failure="running_pod_unavailable" elif ! $KUBECTL wait --for=condition=Ready "$ready_pod_ref" \ -n awoooi-prod --timeout=10s >/dev/null 2>&1; then last_failure="ready_pod_unavailable" else container_ready=$( $KUBECTL get "$ready_pod_ref" -n awoooi-prod \ -o jsonpath="{.status.containerStatuses[?(@.name=='$container')].ready}" \ 2>/dev/null ) || container_ready="" if [ "$container_ready" != "true" ]; then last_failure="container_not_ready" elif identity_output=$( $KUBECTL exec -i "$ready_pod_ref" -n awoooi-prod \ -c "$container" -- env \ AWOOOI_DB_REPRESENTATIVE_PROBE_COUNT="$representative_probe_count" \ AWOOOI_DB_REQUIRED_CONNECTION_BUDGET="$required_connection_budget" \ python - <<'PY' import asyncio import hashlib import json import os from sqlalchemy import text from src.db.base import get_db_context async def main() -> None: try: async with get_db_context("awoooi") as db: result = await db.execute(text(""" SELECT current_user AS role_name, rolconnlimit AS connection_limit, ( SELECT count(*) FROM pg_stat_activity WHERE usename = current_user AND pid <> pg_backend_pid() ) AS active_role_connection_count FROM pg_roles WHERE rolname = current_user """)) row = result.mappings().one() for table_name in ( "incidents", "knowledge_entries", "awooop_run_state", ): await db.execute( text(f'SELECT count(*) FROM "{table_name}"') ) table_gap_count = int((await db.execute(text(""" SELECT count(*) FROM pg_class relation JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace WHERE namespace.nspname = 'public' AND relation.relkind IN ('r', 'p', 'v', 'm') AND ( (has_table_privilege('awoooi', relation.oid, 'SELECT') AND NOT has_table_privilege(current_user, relation.oid, 'SELECT')) OR (has_table_privilege('awoooi', relation.oid, 'INSERT') AND NOT has_table_privilege(current_user, relation.oid, 'INSERT')) OR (has_table_privilege('awoooi', relation.oid, 'UPDATE') AND NOT has_table_privilege(current_user, relation.oid, 'UPDATE')) OR (has_table_privilege('awoooi', relation.oid, 'DELETE') AND NOT has_table_privilege(current_user, relation.oid, 'DELETE')) ) """))).scalar_one()) sequence_gap_count = int((await db.execute(text(""" SELECT count(*) FROM pg_class sequence JOIN pg_namespace namespace ON namespace.oid = sequence.relnamespace WHERE namespace.nspname = 'public' AND sequence.relkind = 'S' AND ( (has_sequence_privilege('awoooi', sequence.oid, 'USAGE') AND NOT has_sequence_privilege(current_user, sequence.oid, 'USAGE')) OR (has_sequence_privilege('awoooi', sequence.oid, 'SELECT') AND NOT has_sequence_privilege(current_user, sequence.oid, 'SELECT')) OR (has_sequence_privilege('awoooi', sequence.oid, 'UPDATE') AND NOT has_sequence_privilege(current_user, sequence.oid, 'UPDATE')) ) """))).scalar_one()) representative_probe_count = int( os.environ["AWOOOI_DB_REPRESENTATIVE_PROBE_COUNT"] ) required_connection_budget = int( os.environ["AWOOOI_DB_REQUIRED_CONNECTION_BUDGET"] ) if representative_probe_count < 1: raise RuntimeError("representative_probe_count_invalid") if required_connection_budget < 1: raise RuntimeError("required_connection_budget_invalid") connection_limit = int(row["connection_limit"]) active_role_connection_count = int( row["active_role_connection_count"] ) available_connection_headroom = ( connection_limit - active_role_connection_count ) async def probe_connection() -> None: async with get_db_context("awoooi") as probe_db: await probe_db.execute(text("SELECT pg_sleep(0.25)")) await asyncio.gather(*( probe_connection() for _ in range(representative_probe_count) )) except Exception as exc: print(json.dumps({ "role_fingerprint": "", "connection_limit": None, "required_connection_budget": None, "active_role_connection_count": None, "available_connection_headroom": None, "representative_connection_probe_count": 0, "representative_preflight_passed": False, "table_privilege_gap_count": None, "sequence_privilege_gap_count": None, "error_type": type(exc).__name__, })) return print(json.dumps({ "role_fingerprint": hashlib.sha256( str(row["role_name"]).encode("utf-8") ).hexdigest(), "connection_limit": connection_limit, "required_connection_budget": required_connection_budget, "active_role_connection_count": active_role_connection_count, "available_connection_headroom": available_connection_headroom, "representative_connection_probe_count": representative_probe_count, "representative_preflight_passed": ( table_gap_count == 0 and sequence_gap_count == 0 and representative_probe_count >= 1 and connection_limit >= required_connection_budget and ( available_connection_headroom >= required_connection_budget ) ), "table_privilege_gap_count": table_gap_count, "sequence_privilege_gap_count": sequence_gap_count, "error_type": None, })) asyncio.run(main()) PY ); then identity_json=$(printf '%s\n' "$identity_output" | tail -n 1) if printf '%s' "$identity_json" | python3 -c ' import json import sys payload = json.load(sys.stdin) valid = ( bool(payload.get("role_fingerprint")) and payload.get("representative_preflight_passed") is True and int( payload.get("representative_connection_probe_count") or 0 ) >= 1 ) raise SystemExit(0 if valid else 1) ' >/dev/null 2>&1; then printf '%s\n' "$identity_json" return 0 fi last_failure="identity_preflight_not_ready" else last_failure="kubectl_exec_failed" fi fi fi echo "workload_db_identity_once_failed workload=$workload container=$container reason=$last_failure" >&2 return 1 } read_workload_db_identity() { workload="$1" retry_backoff_seconds="${AWOOOI_DB_IDENTITY_RETRY_BACKOFF_SECONDS:-5}" case "$retry_backoff_seconds" in ''|*[!0-9]*) retry_backoff_seconds=5 ;; esac attempt=1 while [ "$attempt" -le 3 ]; do if identity_output=$(read_workload_db_identity_once "$@"); then printf '%s\n' "$identity_output" return 0 fi echo "workload_db_identity_exec_retry=$attempt/3;workload=$workload" >&2 if [ "$attempt" -lt 3 ]; then sleep "$retry_backoff_seconds" fi attempt=$((attempt + 1)) done echo "workload_db_identity_exec_exhausted=3;workload=$workload" >&2 printf '%s\n' '{"role_fingerprint":"","connection_limit":null,"required_connection_budget":null,"active_role_connection_count":null,"available_connection_headroom":null,"representative_connection_probe_count":0,"representative_preflight_passed":false,"table_privilege_gap_count":null,"sequence_privilege_gap_count":null,"error_type":"WorkloadDbIdentityAttemptsExhausted"}' return 1 } json_field() { field="$1" python3 -c \ 'import json,sys; value=json.load(sys.stdin).get(sys.argv[1]); print(value if value is not None else "")' \ "$field" } normalize_bool() { case "${1:-}" in true|TRUE|True|1) printf 'true' ;; *) printf 'false' ;; esac } workload_secret_projection() { workload="$1" container="$2" explicit_ref=$($KUBECTL get "deployment/$workload" -n awoooi-prod \ -o jsonpath="{.spec.template.spec.containers[?(@.name=='$container')].env[?(@.name=='DATABASE_URL')].valueFrom.secretKeyRef.name}" \ || true) env_from_refs=$($KUBECTL get "deployment/$workload" -n awoooi-prod \ -o jsonpath="{range .spec.template.spec.containers[?(@.name=='$container')].envFrom[*]}{.secretRef.name}{'\\n'}{end}" \ | sed '/^$/d' | paste -sd, - || true) if [ -n "$explicit_ref" ]; then printf '%s|%s\n' "$explicit_ref" "$env_from_refs" else printf '%s|%s\n' "$env_from_refs" "$env_from_refs" fi } if API_DB_IDENTITY=$(read_workload_db_identity awoooi-api api 1 8); then API_DB_IDENTITY_EXIT=0 else API_DB_IDENTITY_EXIT=$? fi if WORKER_DB_IDENTITY=$(read_workload_db_identity awoooi-worker worker 1 5); then WORKER_DB_IDENTITY_EXIT=0 else WORKER_DB_IDENTITY_EXIT=$? fi if BROKER_DB_IDENTITY=$(read_workload_db_identity \ awoooi-ansible-executor-broker broker 1 2); then BROKER_DB_IDENTITY_EXIT=0 else BROKER_DB_IDENTITY_EXIT=$? fi if [ "$API_DB_IDENTITY_EXIT" -ne 0 ] \ || [ "$WORKER_DB_IDENTITY_EXIT" -ne 0 ] \ || [ "$BROKER_DB_IDENTITY_EXIT" -ne 0 ]; then echo "❌ workload DB identity attempts exhausted before receipt write" exit 1 fi API_DB_ROLE_FINGERPRINT=$(printf '%s' "$API_DB_IDENTITY" \ | json_field role_fingerprint 2>/dev/null || true) WORKER_DB_ROLE_FINGERPRINT=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field role_fingerprint 2>/dev/null || true) BROKER_DB_ROLE_FINGERPRINT=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field role_fingerprint 2>/dev/null || true) API_DB_CONNECTION_LIMIT=$(printf '%s' "$API_DB_IDENTITY" \ | json_field connection_limit 2>/dev/null || true) WORKER_DB_CONNECTION_LIMIT=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field connection_limit 2>/dev/null || true) BROKER_DB_CONNECTION_LIMIT=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field connection_limit 2>/dev/null || true) API_DB_REQUIRED_CONNECTION_BUDGET=$(printf '%s' "$API_DB_IDENTITY" \ | json_field required_connection_budget 2>/dev/null || true) WORKER_DB_REQUIRED_CONNECTION_BUDGET=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field required_connection_budget 2>/dev/null || true) BROKER_DB_REQUIRED_CONNECTION_BUDGET=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field required_connection_budget 2>/dev/null || true) API_DB_ACTIVE_ROLE_CONNECTION_COUNT=$(printf '%s' "$API_DB_IDENTITY" \ | json_field active_role_connection_count 2>/dev/null || true) WORKER_DB_ACTIVE_ROLE_CONNECTION_COUNT=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field active_role_connection_count 2>/dev/null || true) BROKER_DB_ACTIVE_ROLE_CONNECTION_COUNT=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field active_role_connection_count 2>/dev/null || true) API_DB_AVAILABLE_CONNECTION_HEADROOM=$(printf '%s' "$API_DB_IDENTITY" \ | json_field available_connection_headroom 2>/dev/null || true) WORKER_DB_AVAILABLE_CONNECTION_HEADROOM=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field available_connection_headroom 2>/dev/null || true) BROKER_DB_AVAILABLE_CONNECTION_HEADROOM=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field available_connection_headroom 2>/dev/null || true) API_DB_REPRESENTATIVE_PROBE_COUNT=$(printf '%s' "$API_DB_IDENTITY" \ | json_field representative_connection_probe_count 2>/dev/null || true) WORKER_DB_REPRESENTATIVE_PROBE_COUNT=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field representative_connection_probe_count 2>/dev/null || true) BROKER_DB_REPRESENTATIVE_PROBE_COUNT=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field representative_connection_probe_count 2>/dev/null || true) API_DB_REPRESENTATIVE_PREFLIGHT=$(normalize_bool "$(printf '%s' "$API_DB_IDENTITY" \ | json_field representative_preflight_passed 2>/dev/null || true)") WORKER_DB_REPRESENTATIVE_PREFLIGHT=$(normalize_bool "$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field representative_preflight_passed 2>/dev/null || true)") BROKER_DB_REPRESENTATIVE_PREFLIGHT=$(normalize_bool "$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field representative_preflight_passed 2>/dev/null || true)") API_DB_TABLE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$API_DB_IDENTITY" \ | json_field table_privilege_gap_count 2>/dev/null || true) WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field table_privilege_gap_count 2>/dev/null || true) BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field table_privilege_gap_count 2>/dev/null || true) API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$API_DB_IDENTITY" \ | json_field sequence_privilege_gap_count 2>/dev/null || true) WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$WORKER_DB_IDENTITY" \ | json_field sequence_privilege_gap_count 2>/dev/null || true) BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$BROKER_DB_IDENTITY" \ | json_field sequence_privilege_gap_count 2>/dev/null || true) API_DB_CONNECTION_LIMIT=${API_DB_CONNECTION_LIMIT:-0} WORKER_DB_CONNECTION_LIMIT=${WORKER_DB_CONNECTION_LIMIT:-0} BROKER_DB_CONNECTION_LIMIT=${BROKER_DB_CONNECTION_LIMIT:-0} API_DB_REQUIRED_CONNECTION_BUDGET=${API_DB_REQUIRED_CONNECTION_BUDGET:-0} WORKER_DB_REQUIRED_CONNECTION_BUDGET=${WORKER_DB_REQUIRED_CONNECTION_BUDGET:-0} BROKER_DB_REQUIRED_CONNECTION_BUDGET=${BROKER_DB_REQUIRED_CONNECTION_BUDGET:-0} API_DB_ACTIVE_ROLE_CONNECTION_COUNT=${API_DB_ACTIVE_ROLE_CONNECTION_COUNT:--1} WORKER_DB_ACTIVE_ROLE_CONNECTION_COUNT=${WORKER_DB_ACTIVE_ROLE_CONNECTION_COUNT:--1} BROKER_DB_ACTIVE_ROLE_CONNECTION_COUNT=${BROKER_DB_ACTIVE_ROLE_CONNECTION_COUNT:--1} API_DB_AVAILABLE_CONNECTION_HEADROOM=${API_DB_AVAILABLE_CONNECTION_HEADROOM:--1} WORKER_DB_AVAILABLE_CONNECTION_HEADROOM=${WORKER_DB_AVAILABLE_CONNECTION_HEADROOM:--1} BROKER_DB_AVAILABLE_CONNECTION_HEADROOM=${BROKER_DB_AVAILABLE_CONNECTION_HEADROOM:--1} API_DB_REPRESENTATIVE_PROBE_COUNT=${API_DB_REPRESENTATIVE_PROBE_COUNT:-0} WORKER_DB_REPRESENTATIVE_PROBE_COUNT=${WORKER_DB_REPRESENTATIVE_PROBE_COUNT:-0} BROKER_DB_REPRESENTATIVE_PROBE_COUNT=${BROKER_DB_REPRESENTATIVE_PROBE_COUNT:-0} API_DB_TABLE_PRIVILEGE_GAP_COUNT=${API_DB_TABLE_PRIVILEGE_GAP_COUNT:-1} WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT=${WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT:-1} BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT=${BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT:-1} API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=${API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT:-1} WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=${WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT:-1} BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=${BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT:-1} API_DB_PROJECTION=$(workload_secret_projection awoooi-api api) WORKER_DB_PROJECTION=$(workload_secret_projection awoooi-worker worker) BROKER_DB_PROJECTION=$(workload_secret_projection \ awoooi-ansible-executor-broker broker) API_DATABASE_SECRET_REF=${API_DB_PROJECTION%%|*} WORKER_DATABASE_SECRET_REF=${WORKER_DB_PROJECTION%%|*} BROKER_DATABASE_SECRET_REF=${BROKER_DB_PROJECTION%%|*} API_SECRET_ENVFROM_REFS=${API_DB_PROJECTION#*|} WORKER_SECRET_ENVFROM_REFS=${WORKER_DB_PROJECTION#*|} BROKER_SECRET_ENVFROM_REFS=${BROKER_DB_PROJECTION#*|} API_MONOLITHIC_SECRET_ENVFROM_ABSENT=false WORKER_MONOLITHIC_SECRET_ENVFROM_ABSENT=false BROKER_MONOLITHIC_SECRET_ENVFROM_ABSENT=false if [ -z "$API_SECRET_ENVFROM_REFS" ]; then API_MONOLITHIC_SECRET_ENVFROM_ABSENT=true fi if [ -z "$WORKER_SECRET_ENVFROM_REFS" ]; then WORKER_MONOLITHIC_SECRET_ENVFROM_ABSENT=true fi if [ -z "$BROKER_SECRET_ENVFROM_REFS" ]; then BROKER_MONOLITHIC_SECRET_ENVFROM_ABSENT=true fi API_DB_NULL_POOL=$(normalize_bool "$($KUBECTL get deployment/awoooi-api \ -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[?(@.name=="api")].env[?(@.name=="DATABASE_NULL_POOL")].value}')") WORKER_DB_NULL_POOL=$(normalize_bool "$($KUBECTL get deployment/awoooi-worker \ -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[?(@.name=="worker")].env[?(@.name=="DATABASE_NULL_POOL")].value}')") BROKER_DB_NULL_POOL=$(normalize_bool "$($KUBECTL get deployment/awoooi-ansible-executor-broker \ -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[?(@.name=="broker")].env[?(@.name=="DATABASE_NULL_POOL")].value}')") DISTINCT_DATABASE_SECRET_REFS=false if [ -n "$API_DATABASE_SECRET_REF" ] \ && [ -n "$WORKER_DATABASE_SECRET_REF" ] \ && [ -n "$BROKER_DATABASE_SECRET_REF" ] \ && [ "$API_DATABASE_SECRET_REF" != "$WORKER_DATABASE_SECRET_REF" ] \ && [ "$API_DATABASE_SECRET_REF" != "$BROKER_DATABASE_SECRET_REF" ] \ && [ "$WORKER_DATABASE_SECRET_REF" != "$BROKER_DATABASE_SECRET_REF" ]; then DISTINCT_DATABASE_SECRET_REFS=true fi DISTINCT_DB_ROLE_FINGERPRINTS=false if [ -n "$API_DB_ROLE_FINGERPRINT" ] \ && [ -n "$WORKER_DB_ROLE_FINGERPRINT" ] \ && [ -n "$BROKER_DB_ROLE_FINGERPRINT" ] \ && [ "$API_DB_ROLE_FINGERPRINT" != "$WORKER_DB_ROLE_FINGERPRINT" ] \ && [ "$API_DB_ROLE_FINGERPRINT" != "$BROKER_DB_ROLE_FINGERPRINT" ] \ && [ "$WORKER_DB_ROLE_FINGERPRINT" != "$BROKER_DB_ROLE_FINGERPRINT" ]; then DISTINCT_DB_ROLE_FINGERPRINTS=true fi DB_IDENTITY_ISOLATED=false if [ "$API_MONOLITHIC_SECRET_ENVFROM_ABSENT" = "true" ] \ && [ "$WORKER_MONOLITHIC_SECRET_ENVFROM_ABSENT" = "true" ] \ && [ "$BROKER_MONOLITHIC_SECRET_ENVFROM_ABSENT" = "true" ] \ && [ "$DISTINCT_DATABASE_SECRET_REFS" = "true" ] \ && [ "$DISTINCT_DB_ROLE_FINGERPRINTS" = "true" ]; then DB_IDENTITY_ISOLATED=true fi REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED=false if [ "$API_DB_REPRESENTATIVE_PREFLIGHT" = "true" ] \ && [ "$WORKER_DB_REPRESENTATIVE_PREFLIGHT" = "true" ] \ && [ "$BROKER_DB_REPRESENTATIVE_PREFLIGHT" = "true" ] \ && [ "$API_DB_REPRESENTATIVE_PROBE_COUNT" -ge 1 ] \ && [ "$WORKER_DB_REPRESENTATIVE_PROBE_COUNT" -ge 1 ] \ && [ "$BROKER_DB_REPRESENTATIVE_PROBE_COUNT" -ge 1 ] \ && [ "$API_DB_TABLE_PRIVILEGE_GAP_COUNT" -eq 0 ] \ && [ "$WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT" -eq 0 ] \ && [ "$BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT" -eq 0 ] \ && [ "$API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" -eq 0 ] \ && [ "$WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" -eq 0 ] \ && [ "$BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" -eq 0 ]; then REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED=true fi CONNECTION_BUDGET_VERIFIED=false if [ "$DB_IDENTITY_ISOLATED" = "true" ] \ && [ "$REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED" = "true" ] \ && [ "$API_DB_NULL_POOL" = "true" ] \ && [ "$WORKER_DB_NULL_POOL" = "true" ] \ && [ "$BROKER_DB_NULL_POOL" = "true" ] \ && [ "$API_DB_CONNECTION_LIMIT" -eq 12 ] \ && [ "$WORKER_DB_CONNECTION_LIMIT" -eq 8 ] \ && [ "$BROKER_DB_CONNECTION_LIMIT" -eq 4 ] \ && [ "$API_DB_REQUIRED_CONNECTION_BUDGET" -eq 8 ] \ && [ "$WORKER_DB_REQUIRED_CONNECTION_BUDGET" -eq 5 ] \ && [ "$BROKER_DB_REQUIRED_CONNECTION_BUDGET" -eq 2 ] \ && [ "$API_DB_ACTIVE_ROLE_CONNECTION_COUNT" -ge 0 ] \ && [ "$WORKER_DB_ACTIVE_ROLE_CONNECTION_COUNT" -ge 0 ] \ && [ "$BROKER_DB_ACTIVE_ROLE_CONNECTION_COUNT" -ge 0 ] \ && [ "$API_DB_AVAILABLE_CONNECTION_HEADROOM" -ge "$API_DB_REQUIRED_CONNECTION_BUDGET" ] \ && [ "$WORKER_DB_AVAILABLE_CONNECTION_HEADROOM" -ge "$WORKER_DB_REQUIRED_CONNECTION_BUDGET" ] \ && [ "$BROKER_DB_AVAILABLE_CONNECTION_HEADROOM" -ge "$BROKER_DB_REQUIRED_CONNECTION_BUDGET" ]; then CONNECTION_BUDGET_VERIFIED=true fi api_restart_total() { $KUBECTL get pods -n awoooi-prod -l app=awoooi-api -o json \ | python3 -c ' import json import sys payload = json.load(sys.stdin) print(sum( int(status.get("restartCount") or 0) for item in payload.get("items", []) for status in (item.get("status", {}).get("containerStatuses") or []) if status.get("name") == "api" )) ' } API_RESTART_COUNT_BEFORE=$(api_restart_total) API_DESIRED_REPLICA_COUNT=$($KUBECTL get deployment awoooi-api \ -n awoooi-prod -o jsonpath='{.spec.replicas}') API_HTTP_CONCURRENCY_VERIFIED=$(python3 - <<'PY' import concurrent.futures import os import sys import time import urllib.error import urllib.request readiness_url = "https://awoooi.wooo.work/api/v1/health/ready" db_probe_url = ( "https://awoooi.wooo.work/api/v1/platform/cicd/events" "?project_id=awoooi&limit=1" ) transient_http_statuses = {408, 425, 429, 500, 502, 503, 504} def bounded_int( name: str, default: int, minimum: int, maximum: int, ) -> int: try: value = int(os.environ.get(name, str(default))) except (TypeError, ValueError): value = default return max(minimum, min(value, maximum)) readiness_attempts = bounded_int( "API_PUBLIC_READINESS_ATTEMPTS", 12, 1, 20 ) readiness_delay_seconds = bounded_int( "API_PUBLIC_READINESS_DELAY_SECONDS", 5, 0, 30 ) concurrency_attempts = bounded_int( "API_HTTP_CONCURRENCY_ATTEMPTS", 4, 1, 8 ) concurrency_delay_seconds = bounded_int( "API_HTTP_CONCURRENCY_DELAY_SECONDS", 5, 0, 30 ) def fetch_status(url: str, timeout: int) -> tuple[int | None, str]: try: with urllib.request.urlopen(url, timeout=timeout) as response: return int(response.status), "response" except urllib.error.HTTPError as exc: return int(exc.code), "HTTPError" except Exception as exc: return None, type(exc).__name__ readiness_result = "unavailable" for readiness_attempt in range(1, readiness_attempts + 1): status, error_type = fetch_status(readiness_url, timeout=10) if ( status is not None and status != 200 and status not in transient_http_statuses ): raise SystemExit( "API sequential readiness permanent HTTP status: " + str(status) ) readiness_result = ( "status=" + (str(status) if status is not None else "unavailable") + ";error=" + error_type ) if status == 200: print( "api_sequential_readiness=ready;attempt=" + str(readiness_attempt), file=sys.stderr, ) break if readiness_attempt < readiness_attempts: time.sleep(readiness_delay_seconds) else: raise SystemExit( "API sequential readiness failed: " + readiness_result ) def fetch(url: str) -> int: status, error_type = fetch_status(url, timeout=20) if status is None: raise RuntimeError(error_type) return status last_result = "unavailable" concurrency_urls = (db_probe_url,) * 8 for attempt in range(1, concurrency_attempts + 1): try: with concurrent.futures.ThreadPoolExecutor( max_workers=8 ) as executor: statuses = list(executor.map(fetch, concurrency_urls)) except Exception as exc: last_result = type(exc).__name__ else: last_result = ",".join(str(status) for status in statuses) if all(status == 200 for status in statuses): print("true") break permanent_statuses = sorted({ status for status in statuses if status != 200 and status not in transient_http_statuses }) if permanent_statuses: raise SystemExit( "API database concurrency probe permanent HTTP " "status: " + ",".join( str(status) for status in permanent_statuses ) ) if attempt < concurrency_attempts: time.sleep(concurrency_delay_seconds) else: print( "API database concurrency probe degraded: " + last_result, file=sys.stderr, ) print("false") PY ) if [ "$API_HTTP_CONCURRENCY_VERIFIED" != "true" ]; then CONNECTION_BUDGET_VERIFIED=false fi API_POST_PROBE_ROLLOUT_READY=false if $KUBECTL rollout status deployment/awoooi-api -n awoooi-prod \ --timeout=120s >/dev/null 2>&1; then API_POST_PROBE_ROLLOUT_READY=true fi API_RESTART_COUNT_AFTER=$(api_restart_total) API_READY_REPLICA_COUNT=$($KUBECTL get deployment awoooi-api \ -n awoooi-prod -o jsonpath='{.status.readyReplicas}') API_READY_REPLICA_COUNT=${API_READY_REPLICA_COUNT:-0} API_PROBE_RESTART_FREE=false if [ "$API_RESTART_COUNT_AFTER" -eq "$API_RESTART_COUNT_BEFORE" ]; then API_PROBE_RESTART_FREE=true fi if [ "$API_POST_PROBE_ROLLOUT_READY" != "true" ] \ || [ "$API_PROBE_RESTART_FREE" != "true" ] \ || [ "$API_READY_REPLICA_COUNT" -ne "$API_DESIRED_REPLICA_COUNT" ]; then echo "API concurrency probe destabilized rollout: ready=${API_READY_REPLICA_COUNT}/${API_DESIRED_REPLICA_COUNT} restarts=${API_RESTART_COUNT_BEFORE}->${API_RESTART_COUNT_AFTER}" >&2 exit 1 fi BOUNDARY_VERIFIED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") $KUBECTL create configmap awoooi-executor-boundary-verification \ -n awoooi-prod \ --from-literal=schema_version=awoooi_executor_boundary_verification_v4 \ --from-literal=verified_source_sha="$SOURCE_REVISION" \ --from-literal=verified_at="$BOUNDARY_VERIFIED_AT" \ --from-literal=workflow_run_id="$WORKFLOW_RUN_ID" \ --from-literal=verifier=kubectl_auth_can_i_and_live_mount_readback \ --from-literal=api_service_account="$API_SA" \ --from-literal=worker_service_account="$WORKER_SA" \ --from-literal=broker_service_account="$BROKER_SA" \ --from-literal=api_mutation_denied=true \ --from-literal=api_ssh_mount_absent=true \ --from-literal=worker_mutation_denied=true \ --from-literal=worker_ssh_mount_absent=true \ --from-literal=broker_kubernetes_token_absent=true \ --from-literal=broker_ssh_mount_present=true \ --from-literal=legacy_executor_binding_absent=true \ --from-literal=api_ssh_egress_denied=true \ --from-literal=worker_ssh_egress_denied=true \ --from-literal=broker_ssh_egress_allowlisted=true \ --from-literal=legacy_namespace_egress_allow_absent=true \ --from-literal=single_writer_source_verifier=cd_tests_and_production_source_sha_v1 \ --from-literal=decision_manager_queue_only=true \ --from-literal=webhook_router_queue_only=true \ --from-literal=failure_watcher_queue_only=true \ --from-literal=auto_approved_direct_execution_blocked=true \ --from-literal=candidate_idempotency_contract_verified=true \ --from-literal=apply_idempotency_contract_verified=true \ --from-literal=critical_break_glass_contract_verified=true \ --from-literal=independent_post_verifier_source_verifier=cd_tests_and_production_source_sha_v1 \ --from-literal=asset_postcondition_registry_complete=true \ --from-literal=independent_post_verifier_read_only=true \ --from-literal=executor_returncode_not_success_source=true \ --from-literal=verifier_failure_marks_apply_failed=true \ --from-literal=verifier_failure_retry_first=true \ --from-literal=post_verifier_raw_output_not_persisted=true \ --from-literal=canonical_learning_source_verifier=cd_tests_and_production_source_sha_v1 \ --from-literal=canonical_playbook_resolver_complete=true \ --from-literal=km_row_readback_required=true \ --from-literal=playbook_row_readback_required=true \ --from-literal=learning_failure_fail_closed=true \ --from-literal=trust_failure_fail_closed=true \ --from-literal=learning_receipts_public_safe=true \ --from-literal=db_identity_verifier=runtime_role_fingerprint_role_limit_null_pool_http_concurrency_and_representative_preflight_v4 \ --from-literal=api_database_secret_ref="$API_DATABASE_SECRET_REF" \ --from-literal=worker_database_secret_ref="$WORKER_DATABASE_SECRET_REF" \ --from-literal=broker_database_secret_ref="$BROKER_DATABASE_SECRET_REF" \ --from-literal=api_db_role_fingerprint="$API_DB_ROLE_FINGERPRINT" \ --from-literal=worker_db_role_fingerprint="$WORKER_DB_ROLE_FINGERPRINT" \ --from-literal=broker_db_role_fingerprint="$BROKER_DB_ROLE_FINGERPRINT" \ --from-literal=api_db_connection_limit="$API_DB_CONNECTION_LIMIT" \ --from-literal=worker_db_connection_limit="$WORKER_DB_CONNECTION_LIMIT" \ --from-literal=broker_db_connection_limit="$BROKER_DB_CONNECTION_LIMIT" \ --from-literal=api_required_connection_budget="$API_DB_REQUIRED_CONNECTION_BUDGET" \ --from-literal=worker_required_connection_budget="$WORKER_DB_REQUIRED_CONNECTION_BUDGET" \ --from-literal=broker_required_connection_budget="$BROKER_DB_REQUIRED_CONNECTION_BUDGET" \ --from-literal=api_active_role_connection_count="$API_DB_ACTIVE_ROLE_CONNECTION_COUNT" \ --from-literal=worker_active_role_connection_count="$WORKER_DB_ACTIVE_ROLE_CONNECTION_COUNT" \ --from-literal=broker_active_role_connection_count="$BROKER_DB_ACTIVE_ROLE_CONNECTION_COUNT" \ --from-literal=api_available_connection_headroom="$API_DB_AVAILABLE_CONNECTION_HEADROOM" \ --from-literal=worker_available_connection_headroom="$WORKER_DB_AVAILABLE_CONNECTION_HEADROOM" \ --from-literal=broker_available_connection_headroom="$BROKER_DB_AVAILABLE_CONNECTION_HEADROOM" \ --from-literal=api_representative_connection_probe_count="$API_DB_REPRESENTATIVE_PROBE_COUNT" \ --from-literal=worker_representative_connection_probe_count="$WORKER_DB_REPRESENTATIVE_PROBE_COUNT" \ --from-literal=broker_representative_connection_probe_count="$BROKER_DB_REPRESENTATIVE_PROBE_COUNT" \ --from-literal=api_representative_db_preflight_passed="$API_DB_REPRESENTATIVE_PREFLIGHT" \ --from-literal=worker_representative_db_preflight_passed="$WORKER_DB_REPRESENTATIVE_PREFLIGHT" \ --from-literal=broker_representative_db_preflight_passed="$BROKER_DB_REPRESENTATIVE_PREFLIGHT" \ --from-literal=api_table_privilege_gap_count="$API_DB_TABLE_PRIVILEGE_GAP_COUNT" \ --from-literal=worker_table_privilege_gap_count="$WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT" \ --from-literal=broker_table_privilege_gap_count="$BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT" \ --from-literal=api_sequence_privilege_gap_count="$API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" \ --from-literal=worker_sequence_privilege_gap_count="$WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" \ --from-literal=broker_sequence_privilege_gap_count="$BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" \ --from-literal=representative_db_preflights_verified="$REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED" \ --from-literal=api_http_concurrency_probe_passed="$API_HTTP_CONCURRENCY_VERIFIED" \ --from-literal=api_http_probe_restart_free="$API_PROBE_RESTART_FREE" \ --from-literal=api_restart_count_before="$API_RESTART_COUNT_BEFORE" \ --from-literal=api_restart_count_after="$API_RESTART_COUNT_AFTER" \ --from-literal=api_ready_replica_count="$API_READY_REPLICA_COUNT" \ --from-literal=api_desired_replica_count="$API_DESIRED_REPLICA_COUNT" \ --from-literal=api_database_null_pool="$API_DB_NULL_POOL" \ --from-literal=worker_database_null_pool="$WORKER_DB_NULL_POOL" \ --from-literal=broker_database_null_pool="$BROKER_DB_NULL_POOL" \ --from-literal=api_monolithic_secret_env_from_absent="$API_MONOLITHIC_SECRET_ENVFROM_ABSENT" \ --from-literal=worker_monolithic_secret_env_from_absent="$WORKER_MONOLITHIC_SECRET_ENVFROM_ABSENT" \ --from-literal=broker_monolithic_secret_env_from_absent="$BROKER_MONOLITHIC_SECRET_ENVFROM_ABSENT" \ --from-literal=distinct_database_secret_refs="$DISTINCT_DATABASE_SECRET_REFS" \ --from-literal=distinct_db_role_fingerprints="$DISTINCT_DB_ROLE_FINGERPRINTS" \ --from-literal=db_identity_isolated="$DB_IDENTITY_ISOLATED" \ --from-literal=connection_budget_verified="$CONNECTION_BUDGET_VERIFIED" \ --dry-run=client -o yaml \ | $KUBECTL apply -f - $KUBECTL annotate configmap \ awoooi-executor-boundary-verification -n awoooi-prod \ argocd.argoproj.io/sync-options=Prune=false \ argocd.argoproj.io/compare-options=IgnoreExtraneous \ --overwrite RECEIPT_SOURCE_SHA=$($KUBECTL get configmap \ awoooi-executor-boundary-verification -n awoooi-prod \ -o jsonpath='{.data.verified_source_sha}') test "$RECEIPT_SOURCE_SHA" = "$SOURCE_REVISION" || { echo "❌ executor boundary receipt source mismatch"; exit 1; } echo "✅ AIA-P0-011 API/worker/broker production boundary verified" if [ "$DB_IDENTITY_ISOLATED" != "true" ] \ || [ "$CONNECTION_BUDGET_VERIFIED" != "true" ]; then echo "⚠️ AIA-P0-011 workload DB identity/budget remains in progress" else echo "✅ AIA-P0-011 workload DB identity/budget verified" fi # Health Check HEALTH_PASS=0 for i in 1 2 3; do set +e HTTP_CODE=$(curl -sS -w "%{http_code}" -o /dev/null --connect-timeout 10 --max-time 20 "${{ env.API_HEALTH_URL }}" 2>/dev/null) CURL_STATUS=$? set -e if [ "$CURL_STATUS" -ne 0 ]; then HTTP_CODE="curl_error_${CURL_STATUS}" fi if [ "$HTTP_CODE" = "200" ]; then echo "✅ API 健康檢查通過" HEALTH_PASS=1 break fi echo "⏳ 嘗試 #$i: HTTP $HTTP_CODE,等待 10s..." sleep 10 done if [ "$HEALTH_PASS" = "0" ]; then record_rollout_risk "public_health_final_failed" echo "❌ API 健康檢查失敗" exit 1 fi SOURCE_REVISION="$SOURCE_REVISION" python3 - <<'PY' import json import os import sys import time import urllib.request expected = os.environ["SOURCE_REVISION"].strip().lower() base_url = "https://awoooi.wooo.work/api/v1/agents/agent-autonomous-runtime-control" attempts = int( os.environ.get("EXECUTOR_BOUNDARY_READBACK_ATTEMPTS", "60") ) last_status = "unavailable" for attempt in range(1, attempts + 1): url = ( f"{base_url}?expected_source_sha={expected}" f"&readback_attempt={attempt}" ) try: with urllib.request.urlopen(url, timeout=10) as response: payload = json.load(response) except Exception as exc: last_status = f"fetch_failed:{type(exc).__name__}" else: boundary = payload.get("executor_trust_boundary") or {} database_boundary = ( boundary.get("database_identity_boundary") or {} ) single_writer_source_boundary = ( boundary.get( "autonomous_single_writer_source_boundary" ) or {} ) independent_post_verifier_source_boundary = ( boundary.get( "independent_post_verifier_source_boundary" ) or {} ) canonical_learning_source_boundary = ( boundary.get("canonical_learning_source_boundary") or {} ) last_status = ( f"status={boundary.get('status') or 'missing'};" "verified_source_sha=" f"{str(boundary.get('verified_source_sha') or '')[:10]};" "deployed_source_sha=" f"{str(boundary.get('deployed_source_sha') or '')[:10]}" ) if ( boundary.get("production_boundary_verified") is True and boundary.get("full_workload_boundary_verified") is True and database_boundary.get("db_identity_isolated") is True and database_boundary.get("connection_budget_verified") is True and single_writer_source_boundary.get( "source_boundary_verified" ) is True and independent_post_verifier_source_boundary.get( "source_boundary_verified" ) is True and canonical_learning_source_boundary.get( "source_boundary_verified" ) is True and str(boundary.get("verified_source_sha") or "").lower() == expected ): print("executor_boundary_public_readback=verified_ready") break if attempt == 1 or attempt % 6 == 0: print( "executor_boundary_public_readback_waiting=" f"{attempt}/{attempts};{last_status}", file=sys.stderr, ) if attempt < attempts: time.sleep(5) else: raise SystemExit( "executor boundary public readback failed: " + last_status ) PY echo "AWOOOI_SECURITY_BOUNDARY_VERIFIED=1" DEPLOY_TERMINAL_RECORDED=1 ARGOCD_WAIT ROLLOUT_EXIT=${PIPESTATUS[0]} set -e WORKLOAD_DB_VERIFY_EXIT=0 cat scripts/ops/awoooi-workload-db-identity-bootstrap.sh | \ ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "AWOOOI_KUBECTL_SUDO=true AWOOOI_KUBECONFIG=/etc/rancher/k3s/k3s.yaml AWOOOI_KUBERNETES_SERVER='${{ env.K8S_API_SERVER }}' AWOOOI_RUN_ID='${GITEA_RUN_NUMBER:-cd}' bash -s -- verify" \ || WORKLOAD_DB_VERIFY_EXIT=$? if [ "$WORKLOAD_DB_VERIFY_EXIT" -ne 0 ]; then restore_deploy_controls || true echo "❌ workload database post-deploy verifier failed" exit "$WORKLOAD_DB_VERIFY_EXIT" fi SECURITY_BOUNDARY_VERIFIED="0" DEPLOY_TERMINAL_RECEIPT=$(grep '^AWOOOI_DEPLOY_TERMINAL_RECEIPT=' \ "$ROLLOUT_LOG" | tail -1 | \ sed 's/^AWOOOI_DEPLOY_TERMINAL_RECEIPT=//' || true) if grep -q '^AWOOOI_SECURITY_BOUNDARY_VERIFIED=1$' "$ROLLOUT_LOG"; then SECURITY_BOUNDARY_VERIFIED="1" fi if [ "$SECURITY_BOUNDARY_VERIFIED" != "1" ]; then restore_deploy_controls || true if [ -n "$DEPLOY_TERMINAL_RECEIPT" ]; then echo "❌ deploy verifier terminal receipt: ${DEPLOY_TERMINAL_RECEIPT}" else echo "❌ executor security boundary did not reach its terminal verifier" fi if [ "$ROLLOUT_EXIT" -eq 0 ]; then exit 1 fi exit "$ROLLOUT_EXIT" fi NETWORK_POLICY_CHANGE_ACTIVE="0" ROLLOUT_RISK="0" ROLLOUT_SUMMARY="" if grep -q '^AWOOOI_ROLLOUT_RISK=1$' "$ROLLOUT_LOG"; then ROLLOUT_RISK="1" ROLLOUT_SUMMARY=$(grep '^AWOOOI_ROLLOUT_SUMMARY=' "$ROLLOUT_LOG" | tail -1 | sed 's/^AWOOOI_ROLLOUT_SUMMARY=//' | cut -c1-700) fi if [ -n "${GITHUB_ENV:-}" ]; then { echo "AWOOI_ROLLOUT_RISK=${ROLLOUT_RISK}" echo "AWOOI_ROLLOUT_SUMMARY=${ROLLOUT_SUMMARY}" } >> "$GITHUB_ENV" fi rm -f "$ROLLOUT_LOG" DEPLOY_READBACK_EXIT=0 python3 - <<'PY' || DEPLOY_READBACK_EXIT=$? import json import os import sys import time import urllib.request expected = os.environ["IMAGE_TAG"].strip().lower() expected_short = expected[:10] url = "https://awoooi.wooo.work/api/v1/agents/delivery-closure-workbench" attempts = int(os.environ.get("DEPLOY_READBACK_ATTEMPTS", "36")) sleep_seconds = int(os.environ.get("DEPLOY_READBACK_SLEEP_SECONDS", "10")) last_error = "" for attempt in range(1, attempts + 1): try: with urllib.request.urlopen(url, timeout=20) as response: payload = json.load(response) except Exception as exc: last_error = f"fetch_failed={type(exc).__name__}" print( "production_deploy_readback_attempt=" f"{attempt}/{attempts};{last_error}", file=sys.stderr, ) else: summary = payload.get("summary") if isinstance(payload, dict) else {} if not isinstance(summary, dict): summary = {} runtime_short = str( summary.get("production_deploy_runtime_build_commit_short_sha") or "" ) desired_short = str( summary.get( "production_deploy_desired_main_api_image_tag_short_sha" ) or "" ) desired_status = str( summary.get( "production_deploy_desired_main_api_image_tag_readback_status" ) or "" ) matches_main = ( summary.get("production_deploy_image_tag_matches_main") is True ) if ( runtime_short == expected_short and desired_short == expected_short and desired_status == "ok" ): print( "✅ Production deploy readback matches this build and " "GitOps desired image tag " f"({expected_short}) on attempt {attempt}/{attempts};" f"matches_main={matches_main}" ) raise SystemExit(0) last_error = ( f"expected={expected_short};runtime={runtime_short};" f"desired={desired_short};desired_status={desired_status};" f"matches_main={matches_main}" ) print( "production_deploy_readback_attempt=" f"{attempt}/{attempts};{last_error}", file=sys.stderr, ) if attempt < attempts: time.sleep(sleep_seconds) print( "production_deploy_readback_mismatch=" + last_error, file=sys.stderr, ) raise SystemExit(1) PY if [ "$DEPLOY_READBACK_EXIT" -ne 0 ]; then restore_deploy_controls || true exit "$DEPLOY_READBACK_EXIT" fi if [ "$ROLLOUT_EXIT" -ne 0 ]; then if [ "$ROLLOUT_RISK" = "1" ]; then ROLLOUT_SUMMARY="${ROLLOUT_SUMMARY}; rollout_exit=${ROLLOUT_EXIT}; production_deploy_readback_matched=true" else ROLLOUT_RISK="1" ROLLOUT_SUMMARY="rollout_exit=${ROLLOUT_EXIT}; production_deploy_readback_matched=true" fi ROLLOUT_SUMMARY=$(printf '%s' "$ROLLOUT_SUMMARY" | cut -c1-700) if [ -n "${GITHUB_ENV:-}" ]; then { echo "AWOOI_ROLLOUT_RISK=${ROLLOUT_RISK}" echo "AWOOI_ROLLOUT_SUMMARY=${ROLLOUT_SUMMARY}" } >> "$GITHUB_ENV" fi echo "⚠️ ArgoCD/rollout wait exited ${ROLLOUT_EXIT}, but production deploy readback matched; treating as rollout risk, not deploy failure." fi if [ "$ROLLOUT_RISK" = "1" ]; then ACTOR="${GITHUB_ACTOR:-${{ github.actor }}}" if AWOOI_CICD_STATUS=pending \ AWOOI_CICD_STAGE=rollout-risk \ AWOOI_CICD_JOB_NAME="AWOOOI 部署完成但仍有風險證據" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_SUMMARY="${ROLLOUT_SUMMARY}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD rollout risk notification mirrored through AWOOI API" else echo "⚠️ CI/CD rollout risk notification failed (non-fatal)" fi fi exit 0 - name: Apply Runtime Image Mirror Policy run: | set -euo pipefail prepare_deploy_key() { mkdir -p "${HOME}/.ssh" umask 077 local source_key="${AWOOOI_DEPLOY_SSH_KEY_PATH:-${HOME}/.ssh/deploy_key}" if [ ! -r "${source_key}" ]; then echo "BLOCKER runtime_image_policy_deploy_key_missing" exit 1 fi if [ "${source_key}" != "${HOME}/.ssh/deploy_key" ]; then cp "${source_key}" "${HOME}/.ssh/deploy_key" fi chmod 600 "${HOME}/.ssh/deploy_key" } prepare_deploy_key DEPLOY_KNOWN_HOSTS="${HOME}/.ssh/deploy_known_hosts" ssh-keyscan -T 5 -t ed25519,rsa,ecdsa "${K8S_SSH_HOST}" \ > "${DEPLOY_KNOWN_HOSTS}" 2>/dev/null test -s "${DEPLOY_KNOWN_HOSTS}" || { echo "BLOCKER runtime_image_policy_host_key_unavailable" exit 1 } RECEIPT_PATH="/tmp/awoooi-runtime-image-mirror-receipt.json" test -s "${RECEIPT_PATH}" || { echo "BLOCKER runtime_image_mirror_receipt_missing" exit 1 } TRACE_ID="aia-p0-006-02a-${GITHUB_RUN_ID:-${GITEA_RUN_NUMBER:-unknown}}" BUNDLE_DIR="$(mktemp -d)" trap 'rm -rf "${BUNDLE_DIR}"' EXIT cp scripts/security/runtime_image_mirror_controller.py \ "${BUNDLE_DIR}/controller.py" cp config/security/runtime-image-mirror-policy.json \ "${BUNDLE_DIR}/policy.json" cp "${RECEIPT_PATH}" "${BUNDLE_DIR}/mirror-receipt.json" tar -C "${BUNDLE_DIR}" -cf - . | \ ssh \ -i "${HOME}/.ssh/deploy_key" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ -o UserKnownHostsFile="${DEPLOY_KNOWN_HOSTS}" \ -o ConnectTimeout=10 \ "wooo@${K8S_SSH_HOST}" \ "set -euo pipefail; TMP_DIR=\$(mktemp -d); trap 'rm -rf \"\${TMP_DIR}\"' EXIT; tar -xf - -C \"\${TMP_DIR}\"; python3 \"\${TMP_DIR}/controller.py\" --policy \"\${TMP_DIR}/policy.json\" --trace-id '${TRACE_ID}' apply --apply --mirror-receipt \"\${TMP_DIR}/mirror-receipt.json\" --kubectl-sudo --kubeconfig /etc/rancher/k3s/k3s.yaml --server '${K8S_API_SERVER}'" echo "runtime_image_policy_stage=verified" - name: Notify Build Deploy Success run: | END_TIME=$(date +%s) DURATION=$((END_TIME - ${{ steps.commit.outputs.start_time }})) ACTOR="${{ github.actor }}" if AWOOI_CICD_STATUS=success \ AWOOI_CICD_STAGE=build-and-deploy \ AWOOI_CICD_JOB_NAME="AWOOOI 建置部署完成" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_DURATION_SECONDS="${DURATION}" \ AWOOI_CICD_SUMMARY="Image build/push + ArgoCD rollout + API health passed" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD build-deploy success notification mirrored through AWOOI API" else echo "⚠️ CI/CD build-deploy success notification failed (non-fatal)" fi # 2026-04-09 Claude Sonnet 4.6: Sprint 5.2 — 同步 ops 腳本到 188 (ollama user) # 188 deploy key is rotated and must not be read by this disabled step. # 腳本: docker-health-monitor.sh + pg-backup.sh + notify-awoooi-ops.sh # 感知層與備份通知都先走 AWOOI API/AwoooP,Telegram 直發只保留 API 離線 fallback。 - name: Sync Ops Scripts to 188 # 2026-05-13 Codex T14e/P0: # Disabled until the 188 ops sync path is moved to a file-secret or # Ansible-controlled channel. Gitea Actions logs step env values, and # multiline SSH secrets must not be exposed through CD logs. if: ${{ false }} continue-on-error: true run: | echo "188 ops script sync disabled pending secure key rotation path" - name: Notify Pipeline Failure if: failure() env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} run: | COMMIT_MSG="${{ steps.commit.outputs.message }}" SHORT_SHA="${{ steps.commit.outputs.short_sha }}" ACTOR="${{ github.actor }}" FAILURE_SUMMARY="${AWOOI_ROLLOUT_SUMMARY:-${COMMIT_MSG}}" COMMIT_ESC=$(echo "$COMMIT_MSG" | sed 's/&/\&/g; s//\>/g') MSG=$(printf '❌ AWOOOI 部署失敗\n├ 📝 %s\n├ 🔖 %s\n├ 👤 %s\n├ 🏗️ Stage: build-and-deploy\n└ 🔗 http://192.168.0.110:3001/wooo/awoooi/actions' "${COMMIT_ESC}" "${SHORT_SHA}" "${ACTOR}") if AWOOI_CICD_STATUS=failed \ AWOOI_CICD_STAGE=build-and-deploy \ AWOOI_CICD_JOB_NAME="AWOOOI 部署失敗" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_SUMMARY="${FAILURE_SUMMARY}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD build failure notification mirrored through AWOOI API" else echo "AWOOI API notify failed; direct Telegram fallback disabled to preserve AwoooP receipt chain" fi post-deploy-checks: # 2026-06-28 Codex: post-deploy checks belong to real deploy runs; skip # marker/no-op commits already accounted for by the previous deploy run. # 2026-07-01 Codex: `[metadata-only]` commits do not roll a new image, so # post-deploy smokes would only retest the previous production artifact. if: ${{ github.event_name != 'push' || (!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, 'cancel-stale-cd') && !contains(github.event.head_commit.message, '[metadata-only]')) }} needs: [build-and-deploy] timeout-minutes: 30 # 2026-04-30 Codex: keep post-deploy on the host runner too. Playwright # install-deps can also kill the act-managed job container with RWLayer=nil. runs-on: awoooi-non110-host steps: - name: Bootstrap Host Runner Tools # 2026-05-05 Codex: post-deploy also uses checkout and curl-based # notifications, so it needs the same runner bootstrap as earlier jobs. run: | if command -v apk >/dev/null 2>&1; then apk add --no-cache nodejs npm git curl bash coreutils python3 openssh-client docker-cli docker-cli-buildx fi - name: Checkout source from Gitea env: GITEA_SOURCE_URL: http://192.168.0.110:3001/wooo/awoooi.git run: | set -euo pipefail git init . git remote remove origin 2>/dev/null || true git remote add origin "$GITEA_SOURCE_URL" git fetch --no-tags --prune --depth=1 origin "$GITHUB_SHA" git checkout --force --detach FETCH_HEAD - name: Wait for Host Web Build Pressure # 2026-06-28 Codex: post-deploy is browser-heavy; fail closed on host # pressure until runner load is isolated from production. run: bash scripts/ci/wait-host-web-build-pressure.sh - name: Get Commit Info id: commit run: | echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT echo "message=$(git log -1 --pretty=%s | head -c 50)" >> $GITHUB_OUTPUT echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT - name: Notify Post Deploy Checks Start run: | ACTOR="${{ github.actor }}" if AWOOI_CICD_STATUS=running \ AWOOI_CICD_STAGE=post-deploy-checks \ AWOOI_CICD_JOB_NAME="AWOOOI 部署後驗證開始" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_SUMMARY="Alert Chain / Source Link / Monitoring / Smoke gates started" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD post-deploy start notification mirrored through AWOOI API" else echo "⚠️ CI/CD post-deploy start notification failed (non-fatal)" fi # Phase O-4.5 2026-04-02: Alert Chain Smoke Test (Wave A.6 + B.2 ADR-037) # 驗證告警鏈路 E2E: API Health + Webhook + OTEL + Event Exporter # 2026-04-05 Claude Code cache優化: 使用 /opt/api-venv (已有 requests),移除 Setup Python Tools step # 2026-06-28 Codex: commander controlled automation keeps the canary # evidence and notification signal, but no longer blocks CD completion. - name: Alert Chain Smoke Test id: alert_chain_smoke run: | prepare_deploy_key() { mkdir -p "${HOME}/.ssh" umask 077 local source_key="${AWOOOI_DEPLOY_SSH_KEY_PATH:-${HOME}/.ssh/deploy_key}" if [ ! -r "${source_key}" ]; then echo "❌ deploy ssh key file missing: ${source_key}" >&2 exit 1 fi if [ "${source_key}" != "${HOME}/.ssh/deploy_key" ]; then cp "${source_key}" "${HOME}/.ssh/deploy_key" fi chmod 600 "${HOME}/.ssh/deploy_key" } collect_observability_statuses() { local component="$1" ssh $SSH_OPTS "wooo@${K8S_SSH_HOST}" \ "sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${K8S_API_SERVER} get pods -n observability -l app.kubernetes.io/name=${component} --no-headers -o custom-columns=STATUS:.status.phase" } capture_observability_statuses() { local component="$1" local output if output="$(collect_observability_statuses "${component}" 2>&1)"; then printf '%s' "${output}" return 0 fi printf '%s' "${output}" return 1 } # 2026-05-19 Codex: the smoke test runs inside CI_IMAGE, but the # observability pod checks need the K3s host kubectl context. Capture # those read-only statuses on the host and pass them into the # container, instead of making the container own kube credentials. OBSERVABILITY_PREFLIGHT_ERROR="" OTEL_COLLECTOR_ERROR="" EVENT_EXPORTER_ERROR="" OTEL_COLLECTOR_STATUSES="" EVENT_EXPORTER_STATUSES="" prepare_deploy_key DEPLOY_KNOWN_HOSTS="${HOME}/.ssh/deploy_known_hosts" if ssh-keyscan -T 5 -t ed25519,rsa,ecdsa "${K8S_SSH_HOST}" > "${DEPLOY_KNOWN_HOSTS}" 2>/dev/null && test -s "${DEPLOY_KNOWN_HOSTS}"; then SSH_OPTS="-i ${HOME}/.ssh/deploy_key -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${DEPLOY_KNOWN_HOSTS} -o ConnectTimeout=10" if ! OTEL_COLLECTOR_STATUSES="$(capture_observability_statuses otel-collector)"; then OTEL_COLLECTOR_ERROR="$(printf '%s' "${OTEL_COLLECTOR_STATUSES}" | tail -1 | head -c 200)" OTEL_COLLECTOR_STATUSES="" fi if ! EVENT_EXPORTER_STATUSES="$(capture_observability_statuses event-exporter)"; then EVENT_EXPORTER_ERROR="$(printf '%s' "${EVENT_EXPORTER_STATUSES}" | tail -1 | head -c 200)" EVENT_EXPORTER_STATUSES="" fi else OBSERVABILITY_PREFLIGHT_ERROR="K8s host keyscan failed" OTEL_COLLECTOR_ERROR="${OBSERVABILITY_PREFLIGHT_ERROR}" EVENT_EXPORTER_ERROR="${OBSERVABILITY_PREFLIGHT_ERROR}" fi SOURCE_LINK_RUN_REF="gitea-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}" SOURCE_LINK_CANARY_WORK_ITEM_ID="source-evidence:sentry:upstream_canary:awoooi-source-link-canary-${SOURCE_LINK_RUN_REF}" SOURCE_LINK_CANARY_EVENT_ID="sentry:source_correlation_linked:awoooi-source-link-canary-${SOURCE_LINK_RUN_REF}" echo "source_link_canary_work_item_id=${SOURCE_LINK_CANARY_WORK_ITEM_ID}" >> "$GITHUB_OUTPUT" echo "source_link_canary_event_id=${SOURCE_LINK_CANARY_EVENT_ID}" >> "$GITHUB_OUTPUT" AWOOOP_OPERATOR_API_KEY="$( ssh $SSH_OPTS "wooo@${K8S_SSH_HOST}" \ "sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${K8S_API_SERVER} get secret awoooi-secrets -n awoooi-prod -o jsonpath='{.data.AWOOOP_OPERATOR_API_KEY}' | base64 -d" )" if [ -z "${AWOOOP_OPERATOR_API_KEY}" ]; then echo "❌ AWOOOP_OPERATOR_API_KEY missing from production secret; source-link canary cannot run" echo "alert_chain_status=fail" >> $GITHUB_OUTPUT exit 0 fi export AWOOOP_OPERATOR_API_KEY # 2026-05-05 Codex: use the keepalived VIP instead of a fixed node. # Host runner launches the CI image explicitly to avoid act RWLayer=nil. if docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-alert-smoke" \ --cpus "1.0" \ --memory "1g" \ -v "$PWD:/workspace" \ -v awoooi-api-venv-cache:/opt/api-venv \ -w /workspace \ -e AWOOOI_OTEL_COLLECTOR_STATUSES="${OTEL_COLLECTOR_STATUSES}" \ -e AWOOOI_OTEL_COLLECTOR_ERROR="${OTEL_COLLECTOR_ERROR}" \ -e AWOOOI_EVENT_EXPORTER_STATUSES="${EVENT_EXPORTER_STATUSES}" \ -e AWOOOI_EVENT_EXPORTER_ERROR="${EVENT_EXPORTER_ERROR}" \ -e AWOOOP_OPERATOR_API_KEY \ -e AWOOOP_OPERATOR_ID="gitea-cd-post-deploy" \ -e SOURCE_LINK_RUN_REF="${SOURCE_LINK_RUN_REF}" \ "${{ env.CI_IMAGE }}" \ bash -lc 'set -o pipefail; source /opt/api-venv/bin/activate && python3 scripts/alert_chain_smoke_test.py --api-url ${{ env.ALERT_CHAIN_API_URL }} --source-link-canary-target-incident-id INC-20260505-25E744 --run-ref "${SOURCE_LINK_RUN_REF}" --json | tee /tmp/alert_chain_result.json'; then echo "alert_chain_status=pass" >> $GITHUB_OUTPUT else echo "alert_chain_status=fail" >> $GITHUB_OUTPUT echo "⚠️ Alert Chain smoke failed; continuing under commander controlled automation" exit 0 fi # Phase O-5 Wave C.2 2026-04-02 ogt: 監控覆蓋率驗證 (generate_monitoring.py --check) # 2026-06-28 Codex: coverage remains measured and notified, but no longer # turns a deployed runtime into a blocked terminal CD state by default. - name: Monitoring Coverage Check id: monitoring_coverage run: | if docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-coverage" \ --cpus "1.0" \ --memory "1g" \ -v "$PWD:/workspace" \ -v awoooi-api-venv-cache:/opt/api-venv \ -w /workspace \ "${{ env.CI_IMAGE }}" \ bash -lc 'source /opt/api-venv/bin/activate && python3 scripts/generate_monitoring.py --check'; then echo "coverage_status=pass" >> $GITHUB_OUTPUT else echo "coverage_status=fail" >> $GITHUB_OUTPUT echo "⚠️ Monitoring coverage check failed; continuing under commander controlled automation" exit 0 fi - name: AwoooP Source Correlation Applied-Link Smoke id: source_correlation_apply_smoke run: | SOURCE_LINK_CANARY_WORK_ITEM_ID="${{ steps.alert_chain_smoke.outputs.source_link_canary_work_item_id }}" SOURCE_LINK_CANARY_EVENT_ID="${{ steps.alert_chain_smoke.outputs.source_link_canary_event_id }}" export SOURCE_LINK_CANARY_WORK_ITEM_ID SOURCE_LINK_CANARY_EVENT_ID SOURCE_LINK_SMOKE_TIMEOUT_SECONDS="${SOURCE_LINK_SMOKE_TIMEOUT_SECONDS:-180}" SOURCE_LINK_SMOKE_STATUS=0 if command -v timeout >/dev/null 2>&1; then timeout -k 20s "${SOURCE_LINK_SMOKE_TIMEOUT_SECONDS}s" docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-source-link-smoke" \ --cpus "0.5" \ --memory "512m" \ -v "$PWD:/workspace" \ -v awoooi-api-venv-cache:/opt/api-venv \ -w /workspace \ -e SOURCE_LINK_CANARY_WORK_ITEM_ID \ -e SOURCE_LINK_CANARY_EVENT_ID \ "${{ env.CI_IMAGE }}" \ bash -lc 'set -o pipefail; source /opt/api-venv/bin/activate && python3 scripts/awooop_source_correlation_apply_smoke.py \ --api-url ${{ env.ALERT_CHAIN_API_URL }} \ --target-incident-id INC-20260505-25E744 \ --work-item-id "${SOURCE_LINK_CANARY_WORK_ITEM_ID}" \ --expected-source-event-provider-event-id "${SOURCE_LINK_CANARY_EVENT_ID}" \ --allow-existing-apply \ --refresh-if-stale-days 6 \ --refresh-work-item-id "${SOURCE_LINK_CANARY_WORK_ITEM_ID}" \ --verify-refresh-candidate \ --reviewer-id gitea_cd_source_link_canary \ --operator-note "CD dedicated source-link canary; append-only status-chain proof" \ | tee /tmp/source_correlation_apply_smoke.json' || SOURCE_LINK_SMOKE_STATUS=$? else docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-source-link-smoke" \ --cpus "0.5" \ --memory "512m" \ -v "$PWD:/workspace" \ -v awoooi-api-venv-cache:/opt/api-venv \ -w /workspace \ -e SOURCE_LINK_CANARY_WORK_ITEM_ID \ -e SOURCE_LINK_CANARY_EVENT_ID \ "${{ env.CI_IMAGE }}" \ bash -lc 'set -o pipefail; source /opt/api-venv/bin/activate && python3 scripts/awooop_source_correlation_apply_smoke.py \ --api-url ${{ env.ALERT_CHAIN_API_URL }} \ --target-incident-id INC-20260505-25E744 \ --work-item-id "${SOURCE_LINK_CANARY_WORK_ITEM_ID}" \ --expected-source-event-provider-event-id "${SOURCE_LINK_CANARY_EVENT_ID}" \ --allow-existing-apply \ --refresh-if-stale-days 6 \ --refresh-work-item-id "${SOURCE_LINK_CANARY_WORK_ITEM_ID}" \ --verify-refresh-candidate \ --reviewer-id gitea_cd_source_link_canary \ --operator-note "CD dedicated source-link canary; append-only status-chain proof" \ | tee /tmp/source_correlation_apply_smoke.json' || SOURCE_LINK_SMOKE_STATUS=$? fi echo "source_correlation_apply_rc=${SOURCE_LINK_SMOKE_STATUS}" >> "$GITHUB_OUTPUT" if [ "${SOURCE_LINK_SMOKE_STATUS}" = "0" ]; then echo "source_correlation_apply_status=pass" >> $GITHUB_OUTPUT else echo "source_correlation_apply_status=fail" >> $GITHUB_OUTPUT echo "⚠️ Source correlation applied-link smoke failed; continuing under commander controlled automation rc=${SOURCE_LINK_SMOKE_STATUS}" exit 0 fi # [首席架構師] 新增 Playwright E2E Smoke Test 步驟 v1.0.0 2026-04-01 (台北時間) # continue-on-error: true — smoke 失敗不阻塞部署,但結果會反映在 TG 通知 - name: E2E Smoke Test id: smoke continue-on-error: true run: | cat > /tmp/awoooi-smoke.sh <<'CI_SCRIPT' set -euo pipefail # 首席架構師 Review I4 + 2026-04-05 Claude Code cache優化: # playwright.config.ts import @playwright/test — 必須先安裝 pnpm node_modules # pnpm store 持久化到 /opt/pnpm-store,pnpm-lock.yaml hash 未變則 --prefer-offline SOURCE_WORKDIR=/source SMOKE_WORKDIR=/tmp/awoooi-smoke-workspace cleanup_smoke_workspace_artifacts() { rm -rf "$SMOKE_WORKDIR" \ /tmp/pnpm-install.log \ /tmp/playwright-install-deps.log \ 2>/dev/null || true } trap cleanup_smoke_workspace_artifacts EXIT rm -rf "$SMOKE_WORKDIR" mkdir -p "$SMOKE_WORKDIR" if command -v tar >/dev/null 2>&1; then tar \ --exclude='./.git' \ --exclude='./node_modules' \ --exclude='./apps/web/node_modules' \ --exclude='./apps/web/test-results' \ --exclude='./apps/web/playwright-report' \ --exclude='./packages/*/node_modules' \ -cf - -C "$SOURCE_WORKDIR" . | tar -xf - -C "$SMOKE_WORKDIR" else cp -a "$SOURCE_WORKDIR/." "$SMOKE_WORKDIR/" rm -rf "$SMOKE_WORKDIR/.git" \ "$SMOKE_WORKDIR/node_modules" \ "$SMOKE_WORKDIR/apps/web/node_modules" \ "$SMOKE_WORKDIR/apps/web/test-results" \ "$SMOKE_WORKDIR/apps/web/playwright-report" \ 2>/dev/null || true fi cd "$SMOKE_WORKDIR" PNPM_STORE=/opt/pnpm-store PNPM_HASH_FILE=/opt/pnpm-store/.lock_hash CURRENT_PNPM_HASH=$(md5sum pnpm-lock.yaml | awk '{print $1}') corepack enable 2>/dev/null || npm install -g pnpm@9 -q mkdir -p "$PNPM_STORE" pnpm config set store-dir $PNPM_STORE if [ "$(cat $PNPM_HASH_FILE 2>/dev/null)" != "$CURRENT_PNPM_HASH" ]; then echo "📦 pnpm lock 已變更,重裝 node_modules..." pnpm install --frozen-lockfile 2>&1 | tee /tmp/pnpm-install.log | tail -20 echo "$CURRENT_PNPM_HASH" > $PNPM_HASH_FILE else echo "⚡ 使用快取 pnpm store (lock 未變更),prefer-offline..." pnpm install --frozen-lockfile --prefer-offline 2>&1 | tee /tmp/pnpm-install.log | tail -20 fi pnpm --dir apps/web exec node -e "require.resolve('@playwright/test')" cd apps/web # Playwright Chromium 持久化到 /opt/playwright-browsers,版本 hash guard export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers PLAYWRIGHT_VER=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'] || '')" 2>/dev/null || echo "unknown") PLAYWRIGHT_HASH_FILE=/opt/playwright-browsers/.version_hash BROWSER_PATH=$(node -e "const { chromium } = require('@playwright/test'); console.log(chromium.executablePath())") if [ "$(cat $PLAYWRIGHT_HASH_FILE 2>/dev/null)" != "$PLAYWRIGHT_VER" ] || [ ! -x "$BROWSER_PATH" ]; then echo "📦 Playwright browser cache missing/stale ($PLAYWRIGHT_VER): $BROWSER_PATH" pnpm exec playwright install chromium --with-deps 2>&1 | tail -20 BROWSER_PATH=$(node -e "const { chromium } = require('@playwright/test'); console.log(chromium.executablePath())") test -x "$BROWSER_PATH" || { echo "❌ Playwright browser executable missing after install: $BROWSER_PATH"; exit 1; } echo "$PLAYWRIGHT_VER" > $PLAYWRIGHT_HASH_FILE else echo "⚡ 使用快取 Playwright Chromium ($PLAYWRIGHT_VER): $BROWSER_PATH" fi # Browser cache 命中時也要確認 OS shared libs 存在;否則 smoke 會只測到 # chromium launch failure(例如 libnspr4.so missing)。 if ! ldconfig -p 2>/dev/null | grep -q 'libnspr4'; then echo "📦 Playwright system deps missing,補安裝 Chromium deps..." pnpm exec playwright install-deps chromium > /tmp/playwright-install-deps.log 2>&1 || { tail -40 /tmp/playwright-install-deps.log exit 1 } tail -20 /tmp/playwright-install-deps.log fi # 對已部署的生產環境跑 smoke test SMOKE_STATUS=pass pnpm exec playwright test tests/e2e/smoke.spec.ts --reporter=line || SMOKE_STATUS=fail echo "smoke_status=${SMOKE_STATUS}" >> $GITHUB_OUTPUT CI_SCRIPT SMOKE_OUTPUT="$PWD/.awoooi-smoke-output" rm -f "$SMOKE_OUTPUT" touch "$SMOKE_OUTPUT" chmod 666 "$SMOKE_OUTPUT" SMOKE_DOCKER_STATUS=0 # 2026-06-01 Codex: post-deploy smoke can pass, then hang in # runner cleanup and incorrectly mark the deploy failed. Bound only # the smoke container; preserve pass evidence if it was written. if command -v timeout >/dev/null 2>&1; then # 2026-06-14 Codex: act-runner host may provide BusyBox timeout, # which rejects GNU-only --kill-after. The short -k form works # with BusyBox and GNU timeout. timeout -k 20s 300s docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-e2e-smoke" \ --cpus "1.5" \ --memory "2g" \ -v "$PWD:/source:ro" \ -v "$SMOKE_OUTPUT:/github-output" \ -v /tmp/awoooi-smoke.sh:/tmp/awoooi-smoke.sh:ro \ -v awoooi-pnpm-store:/opt/pnpm-store \ -v awoooi-playwright-browsers:/opt/playwright-browsers \ -w /tmp \ -e GITHUB_OUTPUT=/github-output \ -e CI=true \ -e PLAYWRIGHT_BASE_URL=https://awoooi.wooo.work \ "${{ env.CI_IMAGE }}" \ bash /tmp/awoooi-smoke.sh || SMOKE_DOCKER_STATUS=$? else docker run --rm \ --name "awoooi-cd-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}-e2e-smoke" \ --cpus "1.5" \ --memory "2g" \ -v "$PWD:/source:ro" \ -v "$SMOKE_OUTPUT:/github-output" \ -v /tmp/awoooi-smoke.sh:/tmp/awoooi-smoke.sh:ro \ -v awoooi-pnpm-store:/opt/pnpm-store \ -v awoooi-playwright-browsers:/opt/playwright-browsers \ -w /tmp \ -e GITHUB_OUTPUT=/github-output \ -e CI=true \ -e PLAYWRIGHT_BASE_URL=https://awoooi.wooo.work \ "${{ env.CI_IMAGE }}" \ bash /tmp/awoooi-smoke.sh || SMOKE_DOCKER_STATUS=$? fi if [ "$SMOKE_DOCKER_STATUS" != "0" ] && ! grep -q '^smoke_status=pass$' "$SMOKE_OUTPUT"; then echo "smoke_status=fail" > "$SMOKE_OUTPUT" echo "E2E smoke container failed before pass evidence: ${SMOKE_DOCKER_STATUS}" exit "$SMOKE_DOCKER_STATUS" fi if [ "$SMOKE_DOCKER_STATUS" != "0" ]; then echo "E2E smoke pass evidence was written; treating container exit ${SMOKE_DOCKER_STATUS} as cleanup timeout" fi cat "$SMOKE_OUTPUT" >> "$GITHUB_OUTPUT" env: CI: "true" # 直接測試已部署的生產環境,不啟動本地 dev server PLAYWRIGHT_BASE_URL: "https://awoooi.wooo.work" - name: Notify Health Check Success env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} SMOKE_RESULT: ${{ steps.smoke.outputs.smoke_status == 'pass' && '✅' || '⚠️' }} ALERT_CHAIN_RESULT: ${{ steps.alert_chain_smoke.outputs.alert_chain_status == 'pass' && '✅' || '⚠️' }} MONITORING_RESULT: ${{ steps.monitoring_coverage.outputs.coverage_status == 'pass' && '✅' || '⚠️' }} SOURCE_LINK_RESULT: ${{ steps.source_correlation_apply_smoke.outputs.source_correlation_apply_status == 'pass' && '✅' || '⚠️' }} run: | END_TIME=$(date +%s) DURATION=$((END_TIME - ${{ steps.commit.outputs.start_time }})) MINUTES=$((DURATION / 60)) SECONDS=$((DURATION % 60)) # 2026-04-05 ogt: TG_MSG 必須在 shell 中組裝,才能展開 ${MINUTES}/${SECONDS} 等 shell 變數 # 2026-04-05 ogt: 移除 parse_mode=HTML,避免 commit message 含特殊字元導致 400 COMMIT_MSG="${{ steps.commit.outputs.message }}" SHORT_SHA="${{ steps.commit.outputs.short_sha }}" TG_MSG="✅ AWOOOI 部署完成\n├ 📝 ${COMMIT_MSG}\n├ 🔖 ${SHORT_SHA}\n├ ⏱️ 耗時: ${MINUTES}m ${SECONDS}s\n├ 📦 API: ✅ Web: ✅\n├ 🩺 Health: ✅\n├ 🔗 Alert Chain: ${ALERT_CHAIN_RESULT}\n├ 🧷 Source Link: ${SOURCE_LINK_RESULT}\n├ 📊 Monitoring: ${MONITORING_RESULT}\n└ 🎭 Smoke: ${SMOKE_RESULT}" if AWOOI_CICD_STATUS=success \ AWOOI_CICD_STAGE=post-deploy \ AWOOI_CICD_JOB_NAME="AWOOOI 部署完成" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_DURATION_SECONDS="${DURATION}" \ AWOOI_CICD_SUMMARY="API=✅; Web=✅; AlertChain=${ALERT_CHAIN_RESULT}; SourceLink=${SOURCE_LINK_RESULT}; Monitoring=${MONITORING_RESULT}; Smoke=${SMOKE_RESULT}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD success notification mirrored through AWOOI API" else echo "AWOOI API notify failed; direct Telegram fallback disabled to preserve AwoooP receipt chain" fi - name: Notify Pipeline Failure # 2026-04-16 ogt + Claude Sonnet 4.6: 改用 HTML 結構化格式 if: failure() env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} run: | COMMIT_MSG="${{ steps.commit.outputs.message }}" SHORT_SHA="${{ steps.commit.outputs.short_sha }}" ACTOR="${{ github.actor }}" COMMIT_ESC=$(echo "$COMMIT_MSG" | sed 's/&/\&/g; s//\>/g') MSG=$(printf '❌ AWOOOI 部署失敗\n├ 📝 %s\n├ 🔖 %s\n├ 👤 %s\n├ 🩺 Stage: post-deploy-checks\n└ 🔗 http://192.168.0.110:3001/wooo/awoooi/actions' "${COMMIT_ESC}" "${SHORT_SHA}" "${ACTOR}") if AWOOI_CICD_STATUS=failed \ AWOOI_CICD_STAGE=post-deploy-checks \ AWOOI_CICD_JOB_NAME="AWOOOI 部署失敗" \ AWOOI_CICD_COMMIT_SHA="${GITHUB_SHA}" \ AWOOI_CICD_TRIGGERED_BY="${ACTOR}" \ AWOOI_CICD_SUMMARY="${COMMIT_MSG}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "✅ CI/CD post-deploy failure notification mirrored through AWOOI API" else echo "AWOOI API notify failed; direct Telegram fallback disabled to preserve AwoooP receipt chain" fi - name: Clean Post-Deploy Workspace Artifacts if: always() env: HOST_RUNNER_CLEANUP_IMAGE: ${{ env.CI_IMAGE }} run: bash scripts/ci/cleanup-host-runner-workspace.sh