Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions .github/workflows/gcp-iac.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# GCP IaC — OpenCRE LLM project (Gemini) + Workload Identity Federation
#
# Max-security defaults:
# - workflow_dispatch only (no pull_request)
# - environment gcp-iac (required reviewers + secrets)
# - plan-only unless confirm_apply == APPLY
# - OIDC/WIF after bootstrap; GOOGLE_CREDENTIALS only for first apply
# - action SHAs pinned; terraform_wrapper false
# - never logs GEMINI_API_KEY / credentials JSON
# - persist-credentials: false
#
# Values: GitHub environment secrets + variables (see infra/gcp/README.md).

name: GCP IaC

on:
workflow_dispatch:
inputs:
confirm_apply:
description: "Leave empty for plan-only. Type APPLY (exactly) to apply and PATCH Heroku LLM config."
required: false
default: ""
type: string
create_project:
description: "Terraform create_project. true = create the GCP project."
required: false
default: "true"
type: choice
options:
- "true"
- "false"

permissions:
contents: read
id-token: write
actions: none
attestations: none
checks: none
deployments: none
issues: none
packages: none
pages: none
pull-requests: none
repository-projects: none
security-events: none
statuses: none

concurrency:
group: gcp-iac
cancel-in-progress: false

jobs:
terraform:
name: plan-or-apply
runs-on: ubuntu-24.04
timeout-minutes: 45
environment: gcp-iac
defaults:
run:
working-directory: infra/gcp
shell: bash
env:
TF_IN_AUTOMATION: "1"
TF_INPUT: "0"
CLOUDSDK_CORE_DISABLE_PROMPTS: "1"
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Require identity values
env:
PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
BILLING: ${{ secrets.GCP_BILLING_ACCOUNT_ID }}
HEROKU_APP: ${{ vars.HEROKU_APP }}
WIF: ${{ vars.GCP_WIF_PROVIDER }}
WIF_SA: ${{ vars.GCP_TF_SA_EMAIL }}
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
BOOTSTRAP_JSON: ${{ secrets.GOOGLE_CREDENTIALS }}
run: |
set -euo pipefail
missing=0
[ -n "${PROJECT_ID}" ] || { echo "missing variable GCP_PROJECT_ID"; missing=1; }
[ -n "${BILLING}" ] || { echo "missing secret GCP_BILLING_ACCOUNT_ID"; missing=1; }
[ -n "${HEROKU_APP}" ] || { echo "missing variable HEROKU_APP"; missing=1; }
if [ -n "${WIF}" ] && [ -z "${WIF_SA}" ]; then
echo "GCP_WIF_PROVIDER set but GCP_TF_SA_EMAIL missing"
missing=1
fi
# After bootstrap, remote state is mandatory — never init -backend=false
# on a WIF runner (empty local state could plan duplicate creates).
if [ -n "${WIF}" ] && [ -z "${TF_STATE_BUCKET}" ]; then
echo "GCP_WIF_PROVIDER set but TF_STATE_BUCKET missing (required for remote state)"
missing=1
fi
# Do not keep high-privilege bootstrap JSON once OIDC is configured.
if [ -n "${WIF}" ] && [ -n "${BOOTSTRAP_JSON}" ]; then
echo "Delete secret GOOGLE_CREDENTIALS from environment gcp-iac; WIF is configured"
missing=1
fi
exit "${missing}"

- name: Authenticate to Google Cloud (WIF)
if: ${{ vars.GCP_WIF_PROVIDER != '' }}
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13
with:
create_credentials_file: true
export_environment_variables: true
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_TF_SA_EMAIL }}

- name: Authenticate to Google Cloud (bootstrap JSON)
if: ${{ vars.GCP_WIF_PROVIDER == '' }}
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13
with:
create_credentials_file: true
export_environment_variables: true
credentials_json: ${{ secrets.GOOGLE_CREDENTIALS }}

- name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.9.8
terraform_wrapper: false

- name: Terraform fmt
run: terraform fmt -check -recursive

- name: Terraform init
env:
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
WIF: ${{ vars.GCP_WIF_PROVIDER }}
run: |
set -euo pipefail
if [ -n "${TF_STATE_BUCKET}" ]; then
terraform init -input=false \
-backend-config="bucket=${TF_STATE_BUCKET}" \
-backend-config="prefix=gcp-iac"
elif [ -n "${WIF}" ]; then
echo "Refusing local-state init while WIF is configured (set TF_STATE_BUCKET)"
exit 1
else
echo "TF_STATE_BUCKET unset: local state for first create (migrate after apply)"
terraform init -input=false -backend=false
fi

- name: Terraform validate
run: terraform validate

- name: Terraform plan
env:
TF_VAR_project_id: ${{ vars.GCP_PROJECT_ID }}
TF_VAR_billing_account: ${{ secrets.GCP_BILLING_ACCOUNT_ID }}
TF_VAR_region: ${{ vars.GCP_REGION }}
TF_VAR_budget_usd: ${{ vars.GCP_BUDGET_USD }}
TF_VAR_github_repository: ${{ github.repository }}
TF_VAR_create_project: ${{ inputs.create_project }}
TF_VAR_org_id: ${{ vars.GCP_ORG_ID }}
TF_VAR_folder_id: ${{ vars.GCP_FOLDER_ID }}
run: |
set -euo pipefail
: "${TF_VAR_region:=us-central1}"
: "${TF_VAR_budget_usd:=50}"
: "${TF_VAR_org_id:=}"
: "${TF_VAR_folder_id:=}"
terraform plan -input=false -out="${RUNNER_TEMP}/opencre.tfplan"

- name: Terraform apply
if: ${{ inputs.confirm_apply == 'APPLY' }}
env:
TF_VAR_project_id: ${{ vars.GCP_PROJECT_ID }}
TF_VAR_billing_account: ${{ secrets.GCP_BILLING_ACCOUNT_ID }}
TF_VAR_region: ${{ vars.GCP_REGION }}
TF_VAR_budget_usd: ${{ vars.GCP_BUDGET_USD }}
TF_VAR_github_repository: ${{ github.repository }}
TF_VAR_create_project: ${{ inputs.create_project }}
TF_VAR_org_id: ${{ vars.GCP_ORG_ID }}
TF_VAR_folder_id: ${{ vars.GCP_FOLDER_ID }}
run: |
set -euo pipefail
terraform apply -input=false "${RUNNER_TEMP}/opencre.tfplan"

- name: Migrate state to GCS after first apply
if: ${{ inputs.confirm_apply == 'APPLY' && vars.TF_STATE_BUCKET == '' }}
run: |
set -euo pipefail
bucket="$(terraform output -raw tfstate_bucket)"
terraform init -input=false -force-copy \
-backend-config="bucket=${bucket}" \
-backend-config="prefix=gcp-iac"
echo "Set GitHub Actions variable TF_STATE_BUCKET=${bucket} (environment gcp-iac)."

- name: Push LLM config to Heroku (no value logs)
if: ${{ inputs.confirm_apply == 'APPLY' }}
env:
HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }}
HEROKU_APP: ${{ vars.HEROKU_APP }}
GOOGLE_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
GOOGLE_PROJECT_LOCATION: ${{ vars.GCP_REGION }}
run: |
set -euo pipefail
GEMINI_API_KEY="$(terraform output -raw gemini_api_key)"
echo "::add-mask::${GEMINI_API_KEY}"
export GEMINI_API_KEY
python3 "${GITHUB_WORKSPACE}/scripts/gcp/patch_heroku_llm_config.py"
unset GEMINI_API_KEY

- name: Print WIF identifiers (not secrets)
if: ${{ inputs.confirm_apply == 'APPLY' }}
run: |
set -euo pipefail
echo "GCP_WIF_PROVIDER=$(terraform output -raw wif_provider)"
echo "GCP_TF_SA_EMAIL=$(terraform output -raw terraform_sa_email)"
echo "TF_STATE_BUCKET=$(terraform output -raw tfstate_bucket)"
echo "After WIF vars are set, delete secret GOOGLE_CREDENTIALS from environment gcp-iac."

- name: Shred plan file
if: always()
run: |
set +e
rm -f "${RUNNER_TEMP}/opencre.tfplan"
exit 0
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,24 @@ coverage/
*.db
standards_cache.sqlite

### Terraform
**/.terraform/
*.tfstate
*.tfstate.*
crash.*.log
crash.log
*.tfvars
!infra/gcp/terraform.tfvars.example
override.tf
override.tf.json
*_override.tf
*_override.tf.json

### Docs
*.md
!AGENTS.md
!**/AGENTS.md
!infra/gcp/*.md
!docs/continuity/**/*.md
!docs/continuity/README.md
!docs/faq.md
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ Prefer existing Makefile targets and `scripts/` helpers over ad-hoc Docker, GA,

Second-maintainer ops live in [`docs/continuity/README.md`](docs/continuity/README.md): restart, rollback, health, backup, surgery, deploy, import/sync, gap-analysis cache, staging, local stack, GCP IaC. Agent instructions: [`docs/continuity/runbooks/AGENTS.md`](docs/continuity/runbooks/AGENTS.md). GitHub project **OpenCRE Continuity**, label `existing-maintainers-only`. Do not dump `heroku config` values; do not compute gap analysis on `opencreorg`. If you are logged into Heroku and you still cannot see `opencreorg`, stop and refuse to continue.

### GCP LLM project (IaC)

Google Cloud for chat/embeddings is **Path A**: a new GCP project + restricted `GEMINI_API_KEY` (LiteLLM `gemini/…`), not Vertex. Plan: [`infra/gcp/README.md`](infra/gcp/README.md). Agent file: [`infra/gcp/AGENTS.md`](infra/gcp/AGENTS.md). Workflow: `.github/workflows/gcp-iac.yml` (plan-only unless `confirm_apply=APPLY`). Local check: `make gcp-iac-validate`. Never log `GEMINI_API_KEY` or `GOOGLE_CREDENTIALS`.

### Local Docker services

Use Makefile targets — do not hand-roll `docker run`:
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ alembic-guardrail:
[ -d "./venv" ] && . ./venv/bin/activate &&\
python scripts/check_alembic_revision_guardrail.py

gcp-iac-validate:
cd infra/gcp && terraform fmt -check -recursive && terraform init -backend=false -input=false && terraform validate

oie-pipeline:
[ -d "./venv" ] && . ./venv/bin/activate &&\
PYTHONPATH=. python scripts/run_oie_pipeline.py --cache_file "$(or $(CACHE_FILE),sqlite:///$(CURDIR)/standards_cache.sqlite)" $(OIE_ARGS)
Expand Down
79 changes: 79 additions & 0 deletions application/tests/gcp_patch_heroku_llm_config_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import os
import unittest
from unittest import mock
from urllib.error import HTTPError
from io import BytesIO


class GcpPatchHerokuLlmConfigTest(unittest.TestCase):
def _run_main(self, env: dict[str, str]) -> int:
import importlib.util
from pathlib import Path

path = (
Path(__file__).resolve().parents[2]
/ "scripts"
/ "gcp"
/ "patch_heroku_llm_config.py"
)
spec = importlib.util.spec_from_file_location("patch_heroku_llm_config", path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
with mock.patch.dict(os.environ, env, clear=True):
return int(mod.main())

def test_missing_env_exits_2(self) -> None:
self.assertEqual(self._run_main({}), 2)

def test_heroku_http_error_exits_1_without_raising(self) -> None:
err = HTTPError("https://api.heroku.com", 401, "nope", hdrs=None, fp=BytesIO())
env = {
"HEROKU_API_KEY": "token",
"HEROKU_APP": "opencreorg",
"GEMINI_API_KEY": "secret-key",
"GOOGLE_PROJECT_ID": "demo-proj",
}
with mock.patch("urllib.request.urlopen", side_effect=err):
self.assertEqual(self._run_main(env), 1)

def test_success_patches_expected_keys(self) -> None:
captured: dict[str, object] = {}

class _Resp:
def getcode(self) -> int:
return 200

def read(self) -> bytes:
return b"{}"

def __enter__(self) -> "_Resp":
return self

def __exit__(self, *args: object) -> None:
return None

def _urlopen(req: object, timeout: int = 0) -> _Resp:
captured["full_url"] = getattr(req, "full_url", "")
captured["data"] = getattr(req, "data", b"")
return _Resp()

env = {
"HEROKU_API_KEY": "token",
"HEROKU_APP": "opencreorg",
"GEMINI_API_KEY": "secret-key",
"GOOGLE_PROJECT_ID": "demo-proj",
}
with mock.patch("urllib.request.urlopen", side_effect=_urlopen):
self.assertEqual(self._run_main(env), 0)
self.assertIn("/apps/opencreorg/config-vars", str(captured.get("full_url")))
body = captured["data"]
assert isinstance(body, bytes)
self.assertIn(b"GEMINI_API_KEY", body)
self.assertIn(b"GOOGLE_PROJECT_ID", body)
self.assertIn(b"gemini/gemini-2.5-flash", body)
self.assertNotIn(b"VERTEX_", body)


if __name__ == "__main__":
unittest.main()
Loading
Loading