teleo-infrastructure/.github/workflows/gcp-observatory-read-adapter.yml
twentyOne2x 36fe0d3cc2
Some checks are pending
CI / lint-and-test (push) Waiting to run
Harden Observatory private staging deploy gate (#163)
Merge the T2 readiness and least-privilege staging gate. This does not deploy Cloud Run, mutate Cloud SQL, or change production routing.
2026-07-15 09:55:49 +02:00

316 lines
13 KiB
YAML

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