Skip to content
Merged
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
212 changes: 198 additions & 14 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Sync Cloud Run Env
name: Deploy Cloud Run

on:
push:
Expand All @@ -8,16 +8,20 @@ env:
GCP_PROJECT_ID: interactivebrokersquant
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com
GCP_RUNTIME_SERVICE_ACCOUNT: ibkr-platform-runtime@interactivebrokersquant.iam.gserviceaccount.com
GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy

jobs:
sync-cloud-run-env:
name: Sync Cloud Run Env
name: Deploy / Sync Cloud Run
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
env:
ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }}
ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }}
GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }}
CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }}
Expand Down Expand Up @@ -80,14 +84,28 @@ jobs:
CRISIS_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.CRISIS_ALERT_PUSH_ACCESS_TOKEN }}
CRISIS_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.CRISIS_ALERT_TELEGRAM_BOT_TOKEN }}
steps:
- name: Check whether env sync is enabled
- name: Check whether Cloud Run automation is enabled
id: config
run: |
set -euo pipefail

if [ "${ENABLE_GITHUB_ENV_SYNC:-}" != "true" ]; then
deploy_enabled=false
env_sync_enabled=false

if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ]; then
deploy_enabled=true
fi

if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ]; then
env_sync_enabled=true
fi

echo "deploy_enabled=${deploy_enabled}" >> "$GITHUB_OUTPUT"
echo "env_sync_enabled=${env_sync_enabled}" >> "$GITHUB_OUTPUT"

if [ "${deploy_enabled}" != "true" ] && [ "${env_sync_enabled}" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run env sync because ENABLE_GITHUB_ENV_SYNC is not set to true." >&2
echo "Skipping Cloud Run automation because ENABLE_GITHUB_CLOUD_RUN_DEPLOY and ENABLE_GITHUB_ENV_SYNC are not true." >&2
exit 0
fi

Expand All @@ -98,21 +116,21 @@ jobs:
uses: actions/checkout@v6

- name: Set up Python for strategy requirement resolution
if: steps.config.outputs.enabled == 'true'
if: steps.config.outputs.env_sync_enabled == 'true'
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install strategy status dependencies
if: steps.config.outputs.enabled == 'true'
if: steps.config.outputs.env_sync_enabled == 'true'
run: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install -r requirements.txt

- name: Resolve Cloud Run sync targets
id: strategy_requirements
if: steps.config.outputs.enabled == 'true'
if: steps.config.outputs.env_sync_enabled == 'true'
run: |
set -euo pipefail
sync_plan_json="$(python scripts/build_cloud_run_env_sync_plan.py --json)"
Expand All @@ -123,7 +141,7 @@ jobs:
} >> "$GITHUB_OUTPUT"

- name: Validate env sync inputs
if: steps.config.outputs.enabled == 'true'
if: steps.config.outputs.env_sync_enabled == 'true'
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
Expand Down Expand Up @@ -165,6 +183,57 @@ jobs:
exit 1
fi

- name: Validate deploy inputs
if: steps.config.outputs.deploy_enabled == 'true'
run: |
set -euo pipefail

if [ -z "${CLOUD_RUN_REGION:-}" ]; then
echo "Cloud Run deploy is enabled, but CLOUD_RUN_REGION is missing." >&2
exit 1
fi

mapfile -t target_services < <(python - <<'PY'
import json
import os

raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip()
if raw_targets:
payload = json.loads(raw_targets)
entries = payload.get("targets") if isinstance(payload, dict) else payload
if not isinstance(entries, list):
raise SystemExit("CLOUD_RUN_SERVICE_TARGETS_JSON must be a JSON array or an object with targets")
for entry in entries:
if not isinstance(entry, dict):
raise SystemExit("Each Cloud Run deploy target must be an object")
service = (
entry.get("service")
or entry.get("service_name")
or entry.get("cloud_run_service")
)
runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json")
if not service and isinstance(runtime_target, str):
runtime_target = json.loads(runtime_target)
if not service and isinstance(runtime_target, dict):
service = runtime_target.get("service_name")
service = str(service or "").strip()
if service:
print(service)
raise SystemExit(0)

raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip()
for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","):
service = chunk.strip()
if service:
print(service)
PY
)

if [ "${#target_services[@]}" -eq 0 ]; then
echo "Cloud Run deploy is enabled, but no services were found in CLOUD_RUN_SERVICE_TARGETS_JSON, CLOUD_RUN_SERVICES, or CLOUD_RUN_SERVICE." >&2
exit 1
fi

- name: Authenticate to Google Cloud
id: auth
if: steps.config.outputs.enabled == 'true'
Expand All @@ -180,8 +249,70 @@ jobs:
project_id: ${{ env.GCP_PROJECT_ID }}
version: ">= 416.0.0"

- name: Build, push, and deploy Cloud Run image
if: steps.config.outputs.deploy_enabled == 'true'
run: |
set -euo pipefail

mapfile -t target_services < <(python - <<'PY'
import json
import os

raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip()
if raw_targets:
payload = json.loads(raw_targets)
entries = payload.get("targets") if isinstance(payload, dict) else payload
for entry in entries:
service = (
entry.get("service")
or entry.get("service_name")
or entry.get("cloud_run_service")
)
runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json")
if not service and isinstance(runtime_target, str):
runtime_target = json.loads(runtime_target)
if not service and isinstance(runtime_target, dict):
service = runtime_target.get("service_name")
service = str(service or "").strip()
if service:
print(service)
raise SystemExit(0)

raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip()
for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","):
service = chunk.strip()
if service:
print(service)
PY
)

artifact_registry_hostname="${GCP_ARTIFACT_REGISTRY_HOSTNAME:-${CLOUD_RUN_REGION}-docker.pkg.dev}"
image_repo="${artifact_registry_hostname}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/interactivebrokersplatform/cloud-run"
image="${image_repo}:${GITHUB_SHA}"

gcloud auth configure-docker "${artifact_registry_hostname}" --quiet
docker build --pull -t "${image}" .
docker push "${image}"

for cloud_run_service in "${target_services[@]}"; do
gcloud run deploy "${cloud_run_service}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--platform=managed \
--image="${image}" \
--service-account="${GCP_RUNTIME_SERVICE_ACCOUNT}" \
--ingress=internal \
--max-instances=1 \
--concurrency=1 \
--memory=512Mi \
--cpu=1 \
--timeout=300s \
--labels="managed-by=github-actions,commit-sha=${GITHUB_SHA},github-run-id=${GITHUB_RUN_ID}" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Put the commit label on the revision template

In the deploy+env-sync path, the later wait loop reads spec.template.metadata.labels.commit-sha, but this deploy only passes --labels, which Cloud Run documents as service metadata.labels rather than revision-template spec.template.metadata.labels. With both ENABLE_GITHUB_CLOUD_RUN_DEPLOY=true and ENABLE_GITHUB_ENV_SYNC=true, the image deploy can succeed but the wait step never observes the SHA and times out before syncing env vars.

Useful? React with 👍 / 👎.

--quiet
done

- name: Wait for Cloud Run deployment of current commit
if: steps.config.outputs.enabled == 'true'
if: steps.config.outputs.env_sync_enabled == 'true'
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
Expand Down Expand Up @@ -230,7 +361,7 @@ jobs:
done

- name: Sync Cloud Run environment
if: steps.config.outputs.enabled == 'true'
if: steps.config.outputs.env_sync_enabled == 'true'
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
Expand Down Expand Up @@ -573,9 +704,39 @@ jobs:
mapfile -t target_services < <(python - <<'PY'
import json
import os
plan = json.loads(os.environ["SYNC_PLAN_JSON"])
for target in plan["targets"]:
print(target["service_name"])

raw_plan = os.environ.get("SYNC_PLAN_JSON", "").strip()
if raw_plan:
plan = json.loads(raw_plan)
for target in plan["targets"]:
print(target["service_name"])
raise SystemExit(0)

raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip()
if raw_targets:
payload = json.loads(raw_targets)
entries = payload.get("targets") if isinstance(payload, dict) else payload
for entry in entries:
service = (
entry.get("service")
or entry.get("service_name")
or entry.get("cloud_run_service")
)
runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json")
if not service and isinstance(runtime_target, str):
runtime_target = json.loads(runtime_target)
if not service and isinstance(runtime_target, dict):
service = runtime_target.get("service_name")
service = str(service or "").strip()
if service:
print(service)
raise SystemExit(0)

raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip()
for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","):
service = chunk.strip()
if service:
print(service)
PY
)

Expand Down Expand Up @@ -621,3 +782,26 @@ jobs:
--quiet
done
done

- name: Clean up old Cloud Run images
if: steps.config.outputs.deploy_enabled == 'true'
run: |
set -euo pipefail

artifact_registry_hostname="${GCP_ARTIFACT_REGISTRY_HOSTNAME:-${CLOUD_RUN_REGION}-docker.pkg.dev}"
image_repo="${artifact_registry_hostname}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/interactivebrokersplatform/cloud-run"
old_digests="$(gcloud artifacts docker images list "${image_repo}" \
--project="${GCP_PROJECT_ID}" \
--include-tags \
--format=json \
| python -c 'import json,os,sys; keep=os.environ["GITHUB_SHA"]; rows=json.load(sys.stdin); print("\n".join(row["version"] for row in rows if keep not in set(row.get("tags") or [])))')"

while IFS= read -r digest; do
if [ -z "${digest}" ]; then
continue
fi
gcloud artifacts docker images delete "${image_repo}@${digest}" \
--project="${GCP_PROJECT_ID}" \
--delete-tags \
--quiet
done <<< "${old_digests}"
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PORT=8080

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt ./
RUN python -m pip install --upgrade pip \
&& python -m pip install -r requirements.txt

COPY . .

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude workspace credentials from the image context

When ENABLE_GITHUB_CLOUD_RUN_DEPLOY=true, the workflow authenticates with google-github-actions/auth before docker build, and that action creates gha-creds-*.json in $GITHUB_WORKSPACE by default; with no .dockerignore, COPY . . bakes that temporary GCP credential file (and the checked-out .git metadata) into the pushed Cloud Run image. Add a .dockerignore for gha-creds-*.json, .git/, etc. or build before creating the credentials file.

Useful? React with 👍 / 👎.


CMD ["gunicorn", "--bind", ":8080", "--workers", "1", "--threads", "1", "--timeout", "300", "main:app"]
Loading