Multi-cloud cost-aware CI/CD gate for Terraform plans.
Estimates per-resource monthly cost for AWS and GCP resources in Terraform PRs using real pricing APIs (AWS Pricing API, GCP Cloud Billing Catalog). Posts a cost breakdown as a PR comment on every plan. On merge above a configurable threshold, alerts the FinOps Slack channel and fires a CostGateThresholdExceeded event to the FinOps remediation engine for follow-up review.
- FastAPI on GCP Cloud Run (
us-central1). - 8 pricing adapters:
aws_instance,aws_db_instance,aws_lambda_function,aws_s3_bucket,aws_nat_gateway,google_compute_instance,google_sql_database_instance,google_cloud_run_v2_service. Each is a single file undersrc/adapters/, registered at import time. - GitHub Action triggers on PR open / synchronise / close. On open and synchronise: plans
examples/demo-tf/, POSTs to the service, posts the cost breakdown as a PR comment. On merge above threshold: also fires Slack + an EventBridge event onto the FinOps hub bus. - Zero static credentials. Two distinct OIDC flows:
- GitHub Actions → GCP via Workload Identity Federation (
terraform/wif.tf); the runner exchanges its OIDC token for a short-lived GCP ID token tied to thecost-gate-ciservice account, then uses it as a Cloud Run identity-token bearer. - GCP runtime → AWS via
AssumeRoleWithWebIdentityagainst an IAM OIDC provider foraccounts.google.com(aws/trust-policy.json); the Cloud Run runtime SA federates into a least-privileged role (pricing:GetProducts,events:PutEvents).
- GitHub Actions → GCP via Workload Identity Federation (
- Two-axis pricing model. Each estimate carries
pricing_source(where the rate came from:aws_pricing_api,gcp_billing_catalog,fallback_estimate,stub,unknown) andpricing_basis(what kind of pricing the resource has:fixed,config_dependent,usage_dependent). The two axes are independent — NAT Gateway isbasis=fixed, source=aws_pricing_api; an unmapped resource isbasis=usage_dependent, source=stub. SeeCLAUDE.md(local) for the rationale.
- Cloud Run service has no
roles/run.invokerforallUsers. The GitHub Action authenticates via WIF; no shared secret. - Cross-cloud API calls use federated identity with ephemeral tokens (1-hour expiry, in-process refresh in
src/adapters/_aws_credentials.py). - No AWS credentials in GCP Secret Manager or environment variables. The only secrets in Secret Manager are
slack-webhook-urlandremediation-event-bus-arn— both are non-credential identifiers / opaque webhooks. - Audience pinning: the AWS IAM trust policy condition fixes
accounts.google.com:subto the runtime SA's numeric unique ID. Other GCP identities cannot federate into the role even from the same project.
| Layer | Technology |
|---|---|
| Web framework | FastAPI, Uvicorn |
| AWS clients | aioboto3 (async), boto3 |
| GCP clients | aiohttp + Cloud Billing Catalog REST |
| Auth | google-auth (GCP→AWS federation), aws_lambda_powertools (none — direct REST) |
| IaC | Terraform |
| Container | Docker, Artifact Registry |
| Hosting | Cloud Run (us-central1) |
| Secrets | GCP Secret Manager (Slack + remediation bus only) |
| CI | GitHub Actions, Workload Identity Federation |
finops-agentic-remediation— the downstream remediation engine that consumesCostGateThresholdExceededevents and executes approved rightsizing actions. The two repos share no code; they are connected operationally via theREMEDIATION_EVENT_BUS_ARNenvironment variable on Cloud Run and the same Slack workspace.
# 1. Bootstrap state bucket (once per environment)
cd terraform/bootstrap
terraform init && terraform apply -var="project_id=<PROJECT_ID>"
# 2. Init the main module against the bootstrap-created bucket
cd ..
terraform init -backend-config="bucket=$(cd bootstrap && terraform output -raw state_bucket_name)"
terraform apply -var="project_id=<PROJECT_ID>" -var="github_repo=<OWNER/REPO>"
# 3. Populate the two runtime secrets (Cloud Run mounts these as env vars)
printf 'https://hooks.slack.com/services/...' \
| gcloud secrets versions add slack-webhook-url --data-file=- --project=<PROJECT_ID>
printf 'arn:aws:events:eu-west-2:<AWS_ACCOUNT>:event-bus/finops-hub-bus' \
| gcloud secrets versions add remediation-event-bus-arn --data-file=- --project=<PROJECT_ID>
# 4. Build, push, deploy
docker build -t us-central1-docker.pkg.dev/<PROJECT_ID>/cost-gate/cost-gate:latest .
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
docker push us-central1-docker.pkg.dev/<PROJECT_ID>/cost-gate/cost-gate:latest
gcloud run deploy cost-gate \
--image=us-central1-docker.pkg.dev/<PROJECT_ID>/cost-gate/cost-gate:latest \
--region=us-central1 --project=<PROJECT_ID>
# 5. Wire the GitHub Action (set repository variables, no secrets)
gh variable set WIF_PROVIDER --body "$(terraform -chdir=terraform output -raw wif_provider_name)"
gh variable set CI_SERVICE_ACCOUNT --body "$(terraform -chdir=terraform output -raw ci_service_account_email)"
gh variable set COST_GATE_URL --body "$(terraform -chdir=terraform output -raw cloud_run_url)"
# 6. Open a PR touching examples/demo-tf/ — the workflow runs, posts a comment.
# Merge above the configured threshold to fire the Slack + EventBridge path.The reverse direction (AWS→GCP) federation used by the remediation engine's notifier is configured in terraform/wif_aws_bridge.tf and applies as part of step 2 above.