name: gcp-observatory-read-adapter on: workflow_dispatch: inputs: action: description: Build, preflight, or deploy the protected staging service and run live receipts required: true default: build_only type: choice options: - build_only - preflight_staging - deploy_staging permissions: contents: read id-token: write concurrency: group: gcp-observatory-read-adapter-${{ github.ref }} cancel-in-progress: false env: PROJECT_ID: teleo-501523 REGION: europe-west6 ZONE: europe-west6-a REPOSITORY: teleo IMAGE_NAME: observatory-read-adapter SERVICE_NAME: observatory-read-adapter-staging RUNTIME_SERVICE_ACCOUNT: sa-observatory-read-adapter@teleo-501523.iam.gserviceaccount.com BUILD_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com DEPLOY_SERVICE_ACCOUNT: sa-observatory-deployer@teleo-501523.iam.gserviceaccount.com DB_IAM_USER: sa-observatory-read-adapter@teleo-501523.iam API_KEY_SECRET: observatory-read-api-key-staging BUILD_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github DEPLOY_WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/observatory-read-adapter-main EXPECTED_WORKFLOW_REF: living-ip/teleo-infrastructure/.github/workflows/gcp-observatory-read-adapter.yml@refs/heads/main jobs: build: name: Test, build, and push adapter image runs-on: ubuntu-latest timeout-minutes: 20 outputs: image_ref: ${{ steps.image.outputs.image_ref }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Run focused adapter tests run: | python -m pip install -e '.[dev]' python -m pytest tests/test_observatory_read_adapter.py -q python -m ruff check observatory_read_adapter tests/test_observatory_read_adapter.py - id: auth uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ env.BUILD_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ env.BUILD_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v3 with: project_id: ${{ env.PROJECT_ID }} - name: Configure Artifact Registry run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet - id: image name: Build, smoke, and push run-unique immutable image shell: bash run: | set -euo pipefail tag="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" image="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${tag}" docker build -f Dockerfile.observatory-read-adapter -t "${image}" . docker run --rm --env PYTHONPYCACHEPREFIX=/tmp/pycache \ "${image}" python -m compileall -q /app/observatory_read_adapter docker run --rm "${image}" python -c 'import observatory_read_adapter; print("adapter-import-ok")' docker push "${image}" digest="$(gcloud artifacts docker images describe "${image}" \ --project="${PROJECT_ID}" \ --format='value(image_summary.digest)')" [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] image_ref="${image}@${digest}" printf 'image_ref=%s\n' "${image_ref}" >> "${GITHUB_OUTPUT}" printf 'revision=%s\nimage_ref=%s\n' "${GITHUB_SHA}" "${image_ref}" > observatory-adapter-image.txt - uses: actions/upload-artifact@v4 with: name: observatory-adapter-image path: observatory-adapter-image.txt if-no-files-found: error preflight-staging: name: Verify private staging prerequisites if: ${{ inputs.action != 'build_only' }} needs: build runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Enforce reviewed main-only staging path shell: bash run: | set -euo pipefail [[ "${GITHUB_REF}" == "refs/heads/main" ]] [[ "${GITHUB_WORKFLOW_REF}" == "${EXPECTED_WORKFLOW_REF}" ]] [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]] - id: auth uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ env.DEPLOY_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v3 with: project_id: ${{ env.PROJECT_ID }} - name: Run adapter-specific read-only GCP preflight shell: bash env: IMAGE_REF: ${{ needs.build.outputs.image_ref }} run: | set -euo pipefail python3 ops/check_observatory_read_adapter_gcp_preflight.py \ --image-ref "${IMAGE_REF}" \ --output observatory-read-adapter-preflight.json - uses: actions/upload-artifact@v4 if: always() with: name: observatory-read-adapter-preflight path: observatory-read-adapter-preflight.json if-no-files-found: error deploy-staging: name: Deploy and prove staging read path if: ${{ inputs.action == 'deploy_staging' }} needs: - build - preflight-staging runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: Enforce reviewed main-only staging path shell: bash run: | set -euo pipefail [[ "${GITHUB_REF}" == "refs/heads/main" ]] [[ "${GITHUB_WORKFLOW_REF}" == "${EXPECTED_WORKFLOW_REF}" ]] [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]] - id: auth uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ env.DEPLOY_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ env.DEPLOY_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v3 with: project_id: ${{ env.PROJECT_ID }} - id: deploy name: Deploy bounded GCP staging service shell: bash env: IMAGE_REF: ${{ needs.build.outputs.image_ref }} run: | set -euo pipefail api_key_version="$(gcloud secrets versions list "${API_KEY_SECRET}" \ --project="${PROJECT_ID}" \ --filter='state=ENABLED' \ --sort-by='~createTime' \ --limit=1 \ --format='value(name)')" api_key_version="${api_key_version##*/}" [[ "${api_key_version}" =~ ^[0-9]+$ ]] gcloud run deploy "${SERVICE_NAME}" \ --project="${PROJECT_ID}" \ --region="${REGION}" \ --image="${IMAGE_REF}" \ --execution-environment=gen2 \ --service-account="${RUNTIME_SERVICE_ACCOUNT}" \ --network=teleo-staging-net \ --subnet=teleo-staging-europe-west6 \ --vpc-egress=private-ranges-only \ --ingress=all \ --no-invoker-iam-check \ --port=8080 \ --cpu=1 \ --memory=512Mi \ --concurrency=8 \ --max-instances=2 \ --timeout=15 \ --set-secrets="OBSERVATORY_API_KEY=${API_KEY_SECRET}:${api_key_version}" \ --set-env-vars="GCP_PROJECT_ID=${PROJECT_ID},CLOUD_SQL_INSTANCE=${PROJECT_ID}:${REGION}:teleo-pgvector-standby,DB_NAME=teleo_canonical,DB_IAM_USER=${DB_IAM_USER},DB_AUTHORIZATION_ROLE=kb_observatory_read,SERVICE_REVISION=${GITHUB_SHA}" \ --labels="livingip-tier=gcp-staging,livingip-surface=observatory-read-adapter" \ --quiet printf 'api_key_version=%s\n' "${api_key_version}" >> "${GITHUB_OUTPUT}" - name: Capture positive and negative live receipts shell: bash env: API_KEY_VERSION: ${{ steps.deploy.outputs.api_key_version }} EXPECTED_IMAGE_REF: ${{ needs.build.outputs.image_ref }} run: | set -euo pipefail service_json="$(gcloud run services describe "${SERVICE_NAME}" --project="${PROJECT_ID}" --region="${REGION}" --format=json)" service_url="$(jq -r '.status.url // empty' <<<"${service_json}")" revision="$(jq -r '.status.latestReadyRevisionName // empty' <<<"${service_json}")" runtime_service_account="$(jq -r '.spec.template.spec.serviceAccountName // .template.serviceAccount // empty' <<<"${service_json}")" deployed_image="$(jq -r '.spec.template.spec.containers[0].image // .template.containers[0].image // empty' <<<"${service_json}")" deployed_secret_version="$(jq -r ' [ .spec.template.spec.containers[]?.env[]? | select(.name == "OBSERVATORY_API_KEY") | .valueFrom.secretKeyRef.key ][0] // [ .template.containers[]?.env[]? | select(.name == "OBSERVATORY_API_KEY") | .valueSource.secretKeyRef.version ][0] // empty ' <<<"${service_json}")" expected_digest="${EXPECTED_IMAGE_REF##*@}" api_key="$(gcloud secrets versions access "${API_KEY_VERSION}" --secret="${API_KEY_SECRET}" --project="${PROJECT_ID}")" test -n "${service_url}" test -n "${revision}" test "${runtime_service_account}" = "${RUNTIME_SERVICE_ACCOUNT}" test "${deployed_secret_version}" = "${API_KEY_VERSION}" [[ "${deployed_image}" == "${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}@${expected_digest}" ]] test -n "${api_key}" curl --fail --silent --show-error \ --header "X-Api-Key: ${api_key}" \ "${service_url}/v1/claims/sample" > positive.json jq -e \ --arg project_id "${PROJECT_ID}" \ --arg cloud_sql_instance "${PROJECT_ID}:${REGION}:teleo-pgvector-standby" \ --arg db_iam_user "${DB_IAM_USER}" \ --arg git_sha "${GITHUB_SHA}" ' .schema == "livingip.observatory-canonical-claim.v1" and .read_only == true and .provenance.gcp_project == $project_id and .provenance.cloud_sql_instance == $cloud_sql_instance and .provenance.database == "teleo_canonical" and .provenance.database_principal == $db_iam_user and .provenance.authorization_role == "kb_observatory_read" and .provenance.transaction_read_only == true and .provenance.write_privileges_denied == true and .provenance.service_revision == $git_sha and (.canonical.evidence | length) > 0 and .proposal_ledger.distinct_from_canonical == true ' positive.json >/dev/null anonymous_status="$(curl --silent --output anonymous.json --write-out '%{http_code}' "${service_url}/v1/claims/sample")" write_status="$(curl --silent --output write.json --write-out '%{http_code}' \ --request POST --header "X-Api-Key: ${api_key}" --header 'Content-Type: application/json' \ --data '{"status":"applied"}' "${service_url}/v1/claims/sample")" test "${anonymous_status}" = 401 test "${write_status}" = 405 curl --fail --silent --show-error \ --header "X-Api-Key: ${api_key}" \ "${service_url}/v1/claims/sample" > after-write-attempt.json cmp <(jq -S . positive.json) <(jq -S . after-write-attempt.json) jq -f ops/redact_observatory_read_adapter_receipt.jq \ positive.json > positive-redacted.json jq -n \ --arg service_url "${service_url}" \ --arg revision "${revision}" \ --arg git_sha "${GITHUB_SHA}" \ --arg runtime_service_account "${runtime_service_account}" \ --arg database_principal "${DB_IAM_USER}" \ --arg deployed_image "${deployed_image}" \ --arg api_key_secret_version "${API_KEY_VERSION}" \ --argjson positive "$(cat positive-redacted.json)" \ --arg anonymous_status "${anonymous_status}" \ --arg write_status "${write_status}" \ '{ schema: "livingip.observatory-read-adapter-live-receipt.v1", required_tier: "T3_live_readonly", service_url: $service_url, revision: $revision, git_sha: $git_sha, runtime_service_account: $runtime_service_account, database_principal: $database_principal, deployed_image: $deployed_image, api_key_secret_version: $api_key_secret_version, positive: $positive, negative: { anonymous_http_status: ($anonymous_status | tonumber), authenticated_post_http_status: ($write_status | tonumber), canonical_state_unchanged_after_post: true, database_write_privileges_denied: $positive.provenance.write_privileges_denied }, production_repoint_executed: false }' > observatory-read-adapter-live-receipt.json unset api_key rm -f positive.json positive-redacted.json after-write-attempt.json anonymous.json write.json - uses: actions/upload-artifact@v4 with: name: observatory-read-adapter-live-receipt path: observatory-read-adapter-live-receipt.json if-no-files-found: error