Deploy Cloud Run #450
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy Cloud Run | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| approved_ref: | |
| description: Exact Git ref approved for this operation, for example refs/heads/main. | |
| required: true | |
| type: string | |
| expected_sha: | |
| description: Exact 40-character commit SHA approved for this operation. | |
| required: true | |
| type: string | |
| approve_image_deploy: | |
| description: Create a new Cloud Run revision without traffic. | |
| default: false | |
| required: true | |
| type: boolean | |
| approve_env_secret_sync: | |
| description: Apply Cloud Run environment and Secret Manager reference changes. | |
| default: false | |
| required: true | |
| type: boolean | |
| approve_scheduler_iam_sync: | |
| description: Apply Cloud Scheduler and Cloud Run IAM changes. | |
| default: false | |
| required: true | |
| type: boolean | |
| approve_traffic_shift: | |
| description: Shift Cloud Run traffic to the latest revision. | |
| default: false | |
| required: true | |
| type: boolean | |
| approve_cleanup: | |
| description: Delete old Cloud Run revisions, images, or legacy Scheduler jobs. | |
| default: false | |
| required: true | |
| type: boolean | |
| permissions: | |
| contents: read | |
| env: | |
| GCP_PROJECT_ID: charlesschwabquant | |
| GCP_WORKLOAD_IDENTITY_PROVIDER: projects/401309731911/locations/global/workloadIdentityPools/github-actions/providers/github-main | |
| GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: schwab-platform-deploy@charlesschwabquant.iam.gserviceaccount.com | |
| GCP_RUNTIME_SERVICE_ACCOUNT: schwab-platform-runtime@charlesschwabquant.iam.gserviceaccount.com | |
| GCP_SCHEDULER_SERVICE_ACCOUNT: schwab-platform-scheduler@charlesschwabquant.iam.gserviceaccount.com | |
| GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| sync-cloud-run-env: | |
| name: Deploy / Sync Cloud Run | |
| # Production deployment and environment sync require an explicit operator dispatch. | |
| if: github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| actions: read | |
| 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 }} | |
| CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }} | |
| APPROVED_REF: ${{ inputs.approved_ref }} | |
| EXPECTED_SHA: ${{ inputs.expected_sha }} | |
| APPROVE_IMAGE_DEPLOY: ${{ inputs.approve_image_deploy }} | |
| APPROVE_ENV_SECRET_SYNC: ${{ inputs.approve_env_secret_sync }} | |
| APPROVE_SCHEDULER_IAM_SYNC: ${{ inputs.approve_scheduler_iam_sync }} | |
| APPROVE_TRAFFIC_SHIFT: ${{ inputs.approve_traffic_shift }} | |
| APPROVE_CLEANUP: ${{ inputs.approve_cleanup }} | |
| ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} | |
| QSL_ENABLE_CLOUD_RUN_AUTOMATION: ${{ vars.QSL_ENABLE_CLOUD_RUN_AUTOMATION }} | |
| GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} | |
| CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} | |
| CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} | |
| # This is the validated non-secret service inventory maintained by the | |
| # control plane; legacy Secret support is only a migration fallback. | |
| CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} | |
| CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} | |
| CLOUD_SCHEDULER_MAIN_TIME: ${{ vars.CLOUD_SCHEDULER_MAIN_TIME }} | |
| CLOUD_SCHEDULER_PROBE_TIME: ${{ vars.CLOUD_SCHEDULER_PROBE_TIME }} | |
| CLOUD_SCHEDULER_PRECHECK_TIME: ${{ vars.CLOUD_SCHEDULER_PRECHECK_TIME }} | |
| GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_CLOUD_PROJECT }} | |
| TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} | |
| SCHWAB_API_KEY_SECRET_NAME: ${{ vars.SCHWAB_API_KEY_SECRET_NAME }} | |
| SCHWAB_APP_SECRET_SECRET_NAME: ${{ vars.SCHWAB_APP_SECRET_SECRET_NAME }} | |
| # This is validated deployment intent, not credential material. Prefer the | |
| # control-plane variable while retaining a legacy-secret fallback during migration. | |
| RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} | |
| SCHWAB_DRY_RUN_ONLY: ${{ vars.SCHWAB_DRY_RUN_ONLY }} | |
| SCHWAB_FEATURE_SNAPSHOT_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_PATH }} | |
| SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH }} | |
| SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE }} | |
| SCHWAB_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }} | |
| SCHWAB_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MAX_STALE_DAYS }} | |
| SCHWAB_STRATEGY_CONFIG_PATH: ${{ vars.SCHWAB_STRATEGY_CONFIG_PATH }} | |
| SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON }} | |
| SCHWAB_MIN_RESERVED_CASH_USD: ${{ vars.SCHWAB_MIN_RESERVED_CASH_USD }} | |
| SCHWAB_RESERVED_CASH_RATIO: ${{ vars.SCHWAB_RESERVED_CASH_RATIO }} | |
| SCHWAB_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.SCHWAB_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} | |
| SCHWAB_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.SCHWAB_MARKET_SIGNAL_HANDOFF_INDEX_URI }} | |
| SCHWAB_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.SCHWAB_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} | |
| SCHWAB_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.SCHWAB_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} | |
| SCHWAB_MARKET_SIGNAL_CACHE_DIR: ${{ vars.SCHWAB_MARKET_SIGNAL_CACHE_DIR }} | |
| SCHWAB_MARKET_SIGNAL_REQUIRED: ${{ vars.SCHWAB_MARKET_SIGNAL_REQUIRED }} | |
| SCHWAB_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.SCHWAB_MARKET_SIGNAL_FALLBACK_MODE }} | |
| SCHWAB_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.SCHWAB_MARKET_SIGNAL_MAX_STALE_DAYS }} | |
| STRATEGY_PLUGIN_ALERT_CHANNELS: ${{ vars.STRATEGY_PLUGIN_ALERT_CHANNELS }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY }} | |
| STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS }} | |
| STRATEGY_PLUGIN_ALERT_SMS_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_PROVIDER }} | |
| STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID }} | |
| STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }} | |
| STRATEGY_PLUGIN_ALERT_SMS_SENDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_SENDER }} | |
| STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID }} | |
| STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL }} | |
| STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_DEVICE: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_DEVICE }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_TAGS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_TAGS }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS }} | |
| # Optional strategy overrides; leave unset to inherit the UsEquityStrategies profile defaults. | |
| INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} | |
| QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} | |
| INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} | |
| INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }} | |
| INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} | |
| SCHWAB_CASH_ONLY_EXECUTION: ${{ vars.SCHWAB_CASH_ONLY_EXECUTION }} | |
| CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }} | |
| RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} | |
| DUAL_DRIVE_UNLEVERED_SYMBOL: ${{ vars.DUAL_DRIVE_UNLEVERED_SYMBOL }} | |
| EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} | |
| GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} | |
| NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} | |
| TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} | |
| SCHWAB_API_KEY: ${{ secrets.SCHWAB_API_KEY }} | |
| SCHWAB_APP_SECRET: ${{ secrets.SCHWAB_APP_SECRET }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD }} | |
| STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.TG_TOKEN }} | |
| steps: | |
| - name: Check whether Cloud Run automation is enabled | |
| id: config | |
| run: | | |
| set -euo pipefail | |
| write_github_output() { | |
| printf '%s\n' "$@" >> "$GITHUB_OUTPUT" | |
| } | |
| deploy_enabled=false | |
| env_sync_enabled=false | |
| scheduler_iam_sync_enabled=false | |
| traffic_shift_enabled=false | |
| cleanup_enabled=false | |
| # QSL_ENABLE_CLOUD_RUN_AUTOMATION overrides ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION | |
| ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION="${QSL_ENABLE_CLOUD_RUN_AUTOMATION:-$ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION}" | |
| if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION:-}" != "true" ]; then | |
| write_github_output \ | |
| "deploy_enabled=false" \ | |
| "env_sync_enabled=false" \ | |
| "enabled=false" | |
| echo "Skipping Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2 | |
| exit 0 | |
| fi | |
| if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ] && [ "${APPROVE_IMAGE_DEPLOY:-false}" = "true" ]; then | |
| deploy_enabled=true | |
| fi | |
| if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ] && [ "${APPROVE_ENV_SECRET_SYNC:-false}" = "true" ]; then | |
| env_sync_enabled=true | |
| fi | |
| if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ] && [ "${APPROVE_SCHEDULER_IAM_SYNC:-false}" = "true" ]; then | |
| scheduler_iam_sync_enabled=true | |
| fi | |
| if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ] && [ "${APPROVE_TRAFFIC_SHIFT:-false}" = "true" ]; then | |
| traffic_shift_enabled=true | |
| fi | |
| if [ "${deploy_enabled}" = "true" ] && [ "${CLOUD_RUN_CLEANUP_ENABLED:-}" = "true" ] && [ "${APPROVE_CLEANUP:-false}" = "true" ]; then | |
| cleanup_enabled=true | |
| fi | |
| write_github_output \ | |
| "deploy_enabled=${deploy_enabled}" \ | |
| "env_sync_enabled=${env_sync_enabled}" \ | |
| "scheduler_iam_sync_enabled=${scheduler_iam_sync_enabled}" \ | |
| "traffic_shift_enabled=${traffic_shift_enabled}" \ | |
| "cleanup_enabled=${cleanup_enabled}" | |
| if [ "${deploy_enabled}" != "true" ] && [ "${env_sync_enabled}" != "true" ] && [ "${scheduler_iam_sync_enabled}" != "true" ] && [ "${traffic_shift_enabled}" != "true" ] && [ "${cleanup_enabled}" != "true" ]; then | |
| write_github_output "enabled=false" | |
| echo "Skipping Cloud Run automation because no matching explicit approval is true." >&2 | |
| exit 0 | |
| fi | |
| write_github_output "enabled=true" | |
| - name: Checkout repository | |
| if: steps.config.outputs.enabled == 'true' | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Verify approved source | |
| if: steps.config.outputs.enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "${EXPECTED_SHA}" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "expected_sha must be a 40-character lowercase commit SHA." >&2 | |
| exit 1 | |
| fi | |
| if [ "${APPROVED_REF}" != "${GITHUB_REF}" ]; then | |
| echo "approved_ref does not match the dispatched Git ref." >&2 | |
| exit 1 | |
| fi | |
| checked_out_sha="$(git rev-parse HEAD)" | |
| if [ "${EXPECTED_SHA}" != "${GITHUB_SHA}" ] || [ "${EXPECTED_SHA}" != "${checked_out_sha}" ]; then | |
| echo "expected_sha does not match the dispatched and checked-out commit." >&2 | |
| exit 1 | |
| fi | |
| - name: Set up Python for strategy requirement resolution | |
| if: steps.config.outputs.enabled == 'true' | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Install strategy status dependencies | |
| if: steps.config.outputs.enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| python -m pip install --upgrade pip uv | |
| uv sync --frozen --no-dev | |
| - name: Resolve Cloud Run sync targets | |
| id: strategy_requirements | |
| if: steps.config.outputs.env_sync_enabled == 'true' || steps.config.outputs.scheduler_iam_sync_enabled == 'true' || steps.config.outputs.traffic_shift_enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| sync_plan_json="$(uv run --no-sync python scripts/build_cloud_run_env_sync_plan.py --json)" | |
| { | |
| echo "sync_plan_json<<__SYNC_PLAN_JSON__" | |
| printf '%s\n' "${sync_plan_json}" | |
| echo "__SYNC_PLAN_JSON__" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Validate env sync inputs | |
| if: steps.config.outputs.env_sync_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| run: | | |
| set -euo pipefail | |
| required_vars=( | |
| CLOUD_RUN_REGION | |
| CLOUD_RUN_SERVICE | |
| ) | |
| missing_vars=() | |
| for var_name in "${required_vars[@]}"; do | |
| if [ -z "${!var_name:-}" ]; then | |
| missing_vars+=("${var_name}") | |
| fi | |
| done | |
| if [ -z "${TELEGRAM_TOKEN_SECRET_NAME:-}" ] && [ -z "${TELEGRAM_TOKEN:-}" ]; then | |
| missing_vars+=("TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN") | |
| fi | |
| if [ -z "${SCHWAB_API_KEY_SECRET_NAME:-}" ] && [ -z "${SCHWAB_API_KEY:-}" ]; then | |
| missing_vars+=("SCHWAB_API_KEY_SECRET_NAME or SCHWAB_API_KEY") | |
| fi | |
| if [ -z "${SCHWAB_APP_SECRET_SECRET_NAME:-}" ] && [ -z "${SCHWAB_APP_SECRET:-}" ]; then | |
| missing_vars+=("SCHWAB_APP_SECRET_SECRET_NAME or SCHWAB_APP_SECRET") | |
| fi | |
| python - <<'PY' | |
| import json | |
| import os | |
| from scripts.reconcile_cloud_runtime import select_sync_target | |
| plan = json.loads(os.environ["SYNC_PLAN_JSON"]) | |
| target = select_sync_target(plan, os.environ["CLOUD_RUN_SERVICE"]) | |
| service_name = target["service_name"] | |
| if not isinstance(target.get("env"), dict): | |
| raise SystemExit(f"Cloud Run sync target {service_name} is missing env") | |
| PY | |
| if [ "${#missing_vars[@]}" -gt 0 ]; then | |
| echo "Cloud Run env sync is enabled, but these values are missing:" >&2 | |
| printf ' - %s\n' "${missing_vars[@]}" >&2 | |
| exit 1 | |
| fi | |
| - name: Validate deploy inputs | |
| if: steps.config.outputs.deploy_enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| missing_vars=() | |
| for var_name in CLOUD_RUN_REGION CLOUD_RUN_SERVICE; do | |
| if [ -z "${!var_name:-}" ]; then | |
| missing_vars+=("${var_name}") | |
| fi | |
| done | |
| if [ "${#missing_vars[@]}" -gt 0 ]; then | |
| echo "Cloud Run deploy is enabled, but these values are missing:" >&2 | |
| printf ' - %s\n' "${missing_vars[@]}" >&2 | |
| exit 1 | |
| fi | |
| - name: Authenticate to Google Cloud | |
| id: auth | |
| if: steps.config.outputs.enabled == 'true' | |
| uses: google-github-actions/auth@v3 | |
| with: | |
| workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} | |
| service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} | |
| - name: Set up gcloud | |
| if: steps.config.outputs.enabled == 'true' | |
| uses: google-github-actions/setup-gcloud@v3 | |
| with: | |
| project_id: ${{ env.GCP_PROJECT_ID }} | |
| version: ">= 416.0.0" | |
| - name: Capture no-traffic deployment baseline | |
| if: steps.config.outputs.deploy_enabled == 'true' | |
| env: | |
| DEPLOY_READBACK_FILE: ${{ runner.temp }}/cloud-run-no-traffic-baseline.json | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/verify_cloud_run_no_traffic_deploy.py capture \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --service="${CLOUD_RUN_SERVICE}" \ | |
| --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ | |
| --output="${DEPLOY_READBACK_FILE}" | |
| - name: Verify deployed runtime target admission before traffic shift | |
| if: steps.config.outputs.deploy_enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| uv run --no-sync python scripts/verify_deployed_runtime_target_admission.py \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --service="${CLOUD_RUN_SERVICE}" | |
| - name: Build, push, and deploy Cloud Run image | |
| id: deploy | |
| 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}/charlesschwabplatform/${CLOUD_RUN_SERVICE}" | |
| image="${image_repo}:${GITHUB_SHA}" | |
| gcloud auth configure-docker "${artifact_registry_hostname}" --quiet | |
| docker build --pull -t "${image}" . | |
| docker push "${image}" | |
| image_digest="$(gcloud artifacts docker images describe "${image}" --project="${GCP_PROJECT_ID}" --format='value(image_summary.digest)')" | |
| if [[ ! "${image_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "Artifact Registry did not return a valid image digest." >&2 | |
| exit 1 | |
| fi | |
| immutable_image="${image_repo}@${image_digest}" | |
| echo "image_digest=${image_digest}" >> "${GITHUB_OUTPUT}" | |
| gcloud run deploy "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --platform=managed \ | |
| --image="${immutable_image}" \ | |
| --service-account="${GCP_RUNTIME_SERVICE_ACCOUNT}" \ | |
| --ingress=internal \ | |
| --no-allow-unauthenticated \ | |
| --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}" \ | |
| --no-traffic \ | |
| --quiet | |
| - name: Verify no-traffic deployment readback | |
| if: steps.config.outputs.deploy_enabled == 'true' | |
| env: | |
| DEPLOY_READBACK_FILE: ${{ runner.temp }}/cloud-run-no-traffic-baseline.json | |
| EXPECTED_IMAGE_DIGEST: ${{ steps.deploy.outputs.image_digest }} | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/verify_cloud_run_no_traffic_deploy.py verify \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --service="${CLOUD_RUN_SERVICE}" \ | |
| --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ | |
| --before="${DEPLOY_READBACK_FILE}" \ | |
| --expected-sha="${EXPECTED_SHA}" \ | |
| --expected-image-digest="${EXPECTED_IMAGE_DIGEST}" | |
| - name: Sync Cloud Run environment | |
| if: steps.config.outputs.env_sync_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| run: | | |
| set -euo pipefail | |
| mapfile -t target_env_pairs < <(python - <<'PY' | |
| import json | |
| import os | |
| from scripts.reconcile_cloud_runtime import select_sync_target | |
| plan = json.loads(os.environ["SYNC_PLAN_JSON"]) | |
| target = select_sync_target(plan, os.environ["CLOUD_RUN_SERVICE"]) | |
| for key, value in sorted(target["env"].items()): | |
| print(f"{key}={value}") | |
| PY | |
| ) | |
| mapfile -t target_remove_env_vars < <(python - <<'PY' | |
| import json | |
| import os | |
| from scripts.reconcile_cloud_runtime import select_sync_target | |
| plan = json.loads(os.environ["SYNC_PLAN_JSON"]) | |
| target = select_sync_target(plan, os.environ["CLOUD_RUN_SERVICE"]) | |
| for key in sorted(target.get("remove_env_vars") or []): | |
| print(key) | |
| PY | |
| ) | |
| env_pairs=("${target_env_pairs[@]}") | |
| secret_pairs=() | |
| remove_env_vars=("${target_remove_env_vars[@]}") | |
| remove_env_vars+=( | |
| "TELEGRAM_CHAT_ID" | |
| "CRISIS_ALERT_GOOGLE_VOICE_TO" | |
| "CRISIS_ALERT_GOOGLE_VOICE_GATEWAY" | |
| "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER" | |
| "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" | |
| "CRISIS_ALERT_GOOGLE_VOICE_RECIPIENTS" | |
| "CRISIS_ALERT_GOOGLE_VOICE_SENDER_EMAIL" | |
| "CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD" | |
| "CRISIS_ALERT_GOOGLE_VOICE_SMTP_HOST" | |
| "CRISIS_ALERT_GOOGLE_VOICE_SMTP_PORT" | |
| "CRISIS_ALERT_GOOGLE_VOICE_SMTP_SECURITY" | |
| "CRISIS_ALERT_SMTP_FROM" | |
| "CRISIS_ALERT_SMTP_HOST" | |
| "CRISIS_ALERT_SMTP_PORT" | |
| "CRISIS_ALERT_SMTP_USERNAME" | |
| "CRISIS_ALERT_SMTP_PASSWORD" | |
| "CRISIS_ALERT_SMTP_STARTTLS" | |
| "CRISIS_ALERT_SMTP_SSL" | |
| "CRISIS_ALERT_CHANNELS" | |
| "CRISIS_ALERT_EMAIL_RECIPIENTS" | |
| "CRISIS_ALERT_EMAIL_SENDER_EMAIL" | |
| "CRISIS_ALERT_EMAIL_SENDER_PASSWORD" | |
| "CRISIS_ALERT_EMAIL_SMTP_HOST" | |
| "CRISIS_ALERT_EMAIL_SMTP_PORT" | |
| "CRISIS_ALERT_EMAIL_SMTP_SECURITY" | |
| "CRISIS_ALERT_SMS_RECIPIENTS" | |
| "CRISIS_ALERT_SMS_PROVIDER" | |
| "CRISIS_ALERT_SMS_ACCOUNT_ID" | |
| "CRISIS_ALERT_SMS_AUTH_TOKEN" | |
| "CRISIS_ALERT_SMS_SENDER" | |
| "CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID" | |
| "CRISIS_ALERT_SMS_API_BASE_URL" | |
| "CRISIS_ALERT_SMS_BODY_MAX_CHARS" | |
| "CRISIS_ALERT_PUSH_RECIPIENTS" | |
| "CRISIS_ALERT_PUSH_PROVIDER" | |
| "CRISIS_ALERT_PUSH_APP_TOKEN" | |
| "CRISIS_ALERT_PUSH_ACCESS_TOKEN" | |
| "CRISIS_ALERT_PUSH_API_BASE_URL" | |
| "CRISIS_ALERT_PUSH_DEVICE" | |
| "CRISIS_ALERT_PUSH_PRIORITY" | |
| "CRISIS_ALERT_PUSH_TAGS" | |
| "CRISIS_ALERT_PUSH_BODY_MAX_CHARS" | |
| "CRISIS_ALERT_TELEGRAM_CHAT_IDS" | |
| "CRISIS_ALERT_TELEGRAM_BOT_TOKEN" | |
| "CRISIS_ALERT_TELEGRAM_API_BASE_URL" | |
| "CRISIS_ALERT_TELEGRAM_PARSE_MODE" | |
| "CRISIS_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW" | |
| "CRISIS_ALERT_TELEGRAM_BODY_MAX_CHARS" | |
| ) | |
| remove_secret_vars=( | |
| "CRISIS_ALERT_SMTP_PASSWORD" | |
| "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" | |
| "CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD" | |
| "CRISIS_ALERT_EMAIL_SENDER_PASSWORD" | |
| "CRISIS_ALERT_SMS_AUTH_TOKEN" | |
| "CRISIS_ALERT_PUSH_APP_TOKEN" | |
| "CRISIS_ALERT_PUSH_ACCESS_TOKEN" | |
| "CRISIS_ALERT_TELEGRAM_BOT_TOKEN" | |
| ) | |
| if [ -n "${TELEGRAM_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("TELEGRAM_TOKEN") | |
| else | |
| env_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN}") | |
| remove_secret_vars+=("TELEGRAM_TOKEN") | |
| fi | |
| if [ -n "${SCHWAB_API_KEY_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("SCHWAB_API_KEY=${SCHWAB_API_KEY_SECRET_NAME}:latest") | |
| remove_env_vars+=("SCHWAB_API_KEY") | |
| else | |
| env_pairs+=("SCHWAB_API_KEY=${SCHWAB_API_KEY}") | |
| remove_secret_vars+=("SCHWAB_API_KEY") | |
| fi | |
| if [ -n "${SCHWAB_APP_SECRET_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("SCHWAB_APP_SECRET=${SCHWAB_APP_SECRET_SECRET_NAME}:latest") | |
| remove_env_vars+=("SCHWAB_APP_SECRET") | |
| else | |
| env_pairs+=("SCHWAB_APP_SECRET=${SCHWAB_APP_SECRET}") | |
| remove_secret_vars+=("SCHWAB_APP_SECRET") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_CHANNELS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_CHANNELS=${STRATEGY_PLUGIN_ALERT_CHANNELS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_CHANNELS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER=${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID=${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_SENDER:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER=${STRATEGY_PLUGIN_ALERT_SMS_SENDER}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID=${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER=${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE=${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY=${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_TAGS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS=${STRATEGY_PLUGIN_ALERT_PUSH_TAGS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE=${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW=${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS") | |
| fi | |
| join_by_delimiter() { | |
| local delimiter="$1" | |
| shift | |
| local output="" | |
| local item | |
| for item in "$@"; do | |
| if [ -z "${output}" ]; then | |
| output="${item}" | |
| else | |
| output="${output}${delimiter}${item}" | |
| fi | |
| done | |
| printf '%s' "${output}" | |
| } | |
| monitor_targets_json="$( | |
| python3 scripts/reconcile_cloud_runtime.py build-monitor-targets | |
| )" | |
| env_pairs+=("MONITOR_DISPATCH_TARGETS_JSON=${monitor_targets_json}") | |
| gcloud_args=( | |
| run services update "${CLOUD_RUN_SERVICE}" | |
| --region "${CLOUD_RUN_REGION}" | |
| --remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")" | |
| --update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")" | |
| ) | |
| if [ "${#remove_secret_vars[@]}" -gt 0 ]; then | |
| gcloud_args+=(--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")") | |
| fi | |
| if [ "${#secret_pairs[@]}" -gt 0 ]; then | |
| gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")") | |
| fi | |
| gcloud "${gcloud_args[@]}" | |
| - name: Reconcile Cloud Run traffic | |
| if: steps.config.outputs.traffic_shift_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/reconcile_cloud_runtime.py reconcile-traffic | |
| - name: Verify strategy plugin mounts | |
| if: steps.config.outputs.env_sync_enabled == 'true' | |
| env: | |
| STRATEGY_PLUGIN_MOUNT_ENV_NAMES: SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/verify_cloud_run_strategy_plugin_mounts.py | |
| - name: Sync Cloud Scheduler schedule | |
| id: scheduler_sync | |
| if: steps.config.outputs.scheduler_iam_sync_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| DIRECT_MONITOR_MIGRATION_COMPLETE: ${{ vars.DIRECT_MONITOR_MIGRATION_COMPLETE }} | |
| run: | | |
| set -euo pipefail | |
| scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" | |
| if [ -z "${scheduler_location}" ]; then | |
| echo "Cloud Scheduler schedule sync requires CLOUD_RUN_REGION or CLOUD_SCHEDULER_LOCATION." >&2 | |
| exit 1 | |
| fi | |
| mapfile -t scheduler_config < <(python - <<'PY' | |
| import json | |
| import os | |
| from scripts.reconcile_cloud_runtime import select_sync_target | |
| plan = json.loads(os.environ["SYNC_PLAN_JSON"]) | |
| target = select_sync_target(plan, os.environ["CLOUD_RUN_SERVICE"]) | |
| scheduler = target.get("scheduler") or {} | |
| target_env = target.get("env") or {} | |
| print(str(scheduler.get("timezone") or "America/New_York").strip()) | |
| print(str(scheduler.get("main_time") or os.environ.get("CLOUD_SCHEDULER_MAIN_TIME", "").strip() or "45 15")) | |
| print(str(scheduler.get("probe_time") or os.environ.get("CLOUD_SCHEDULER_PROBE_TIME", "").strip() or "35 9,15")) | |
| print(str(scheduler.get("precheck_time") or os.environ.get("CLOUD_SCHEDULER_PRECHECK_TIME", "").strip() or "45 9")) | |
| print(str(target_env.get("RUNTIME_TARGET_ENABLED") or "true").strip().lower()) | |
| PY | |
| ) | |
| market_timezone="${scheduler_config[0]}" | |
| main_time="${scheduler_config[1]}" | |
| probe_time="${scheduler_config[2]}" | |
| precheck_time="${scheduler_config[3]}" | |
| runtime_target_enabled="${scheduler_config[4]}" | |
| service_url="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --format='value(status.url)' 2>/dev/null || true)" | |
| if [ -z "${service_url}" ]; then | |
| echo "Unable to resolve Cloud Run service URL for ${CLOUD_RUN_SERVICE}; cannot sync scheduler URI." >&2 | |
| exit 1 | |
| fi | |
| gcloud run services add-iam-policy-binding "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --member="serviceAccount:${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --role="roles/run.invoker" \ | |
| --quiet | |
| gcloud run services add-iam-policy-binding "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --member="serviceAccount:${GCP_RUNTIME_SERVICE_ACCOUNT}" \ | |
| --role="roles/run.invoker" \ | |
| --quiet | |
| scheduler_job_candidates=("${CLOUD_RUN_SERVICE}-scheduler") | |
| if [[ "${CLOUD_RUN_SERVICE}" == *-service ]]; then | |
| scheduler_job_candidates+=("${CLOUD_RUN_SERVICE%-service}-scheduler") | |
| fi | |
| job_name="" | |
| current_schedule="" | |
| for candidate_job in "${scheduler_job_candidates[@]}"; do | |
| current_schedule="$(gcloud scheduler jobs describe "${candidate_job}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --format='value(schedule)' 2>/dev/null || true)" | |
| if [ -n "${current_schedule}" ]; then | |
| job_name="${candidate_job}" | |
| break | |
| fi | |
| done | |
| if [ -z "${job_name}" ]; then | |
| job_name="${scheduler_job_candidates[0]}" | |
| fi | |
| if [ -n "${current_schedule}" ]; then | |
| desired_schedule="$(CURRENT_SCHEDULE="${current_schedule}" SCHEDULE_TIME="${main_time}" python - <<'PY' | |
| import os | |
| current_fields = os.environ["CURRENT_SCHEDULE"].split() | |
| time_fields = os.environ["SCHEDULE_TIME"].split() | |
| if len(current_fields) != 5: | |
| raise SystemExit(f"Cloud Scheduler schedule must have 5 fields: {os.environ['CURRENT_SCHEDULE']!r}") | |
| if len(time_fields) == 5: | |
| print(" ".join(time_fields)) | |
| elif len(time_fields) == 2: | |
| print(" ".join([*time_fields, *current_fields[2:]])) | |
| else: | |
| raise SystemExit( | |
| f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}" | |
| ) | |
| PY | |
| )" | |
| else | |
| desired_schedule="$(SCHEDULE_TIME="${main_time}" python - <<'PY' | |
| import os | |
| fields = os.environ["SCHEDULE_TIME"].split() | |
| if len(fields) == 5: | |
| print(" ".join(fields)) | |
| elif len(fields) == 2: | |
| print(" ".join([*fields, "*", "*", "*"])) | |
| else: | |
| raise SystemExit( | |
| f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}" | |
| ) | |
| PY | |
| )" | |
| fi | |
| scheduler_uri="${service_url}/run" | |
| if [ -n "${current_schedule}" ]; then | |
| echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule}, timezone to ${market_timezone}, and URI to ${scheduler_uri}." | |
| gcloud scheduler jobs update http "${job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${scheduler_uri}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --schedule="${desired_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --quiet | |
| else | |
| echo "Creating Cloud Scheduler job ${job_name} schedule ${desired_schedule}, timezone ${market_timezone}, and URI ${scheduler_uri}." | |
| gcloud scheduler jobs create http "${job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${scheduler_uri}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --schedule="${desired_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --attempt-deadline=600s \ | |
| --quiet | |
| fi | |
| managed_scheduler_jobs=("${job_name}") | |
| direct_monitor_description="Managed by CharlesSchwabPlatform direct-monitor-v1" | |
| if [ "${DIRECT_MONITOR_MIGRATION_COMPLETE:-}" = "true" ]; then | |
| desired_precheck_schedule="$(CURRENT_SCHEDULE="${desired_schedule}" SCHEDULE_TIME="${precheck_time}" python - <<'PY' | |
| import os | |
| current_fields = os.environ["CURRENT_SCHEDULE"].split() | |
| time_fields = os.environ["SCHEDULE_TIME"].split() | |
| if len(current_fields) != 5: | |
| raise SystemExit(f"Cloud Scheduler schedule must have 5 fields: {os.environ['CURRENT_SCHEDULE']!r}") | |
| if len(time_fields) == 5: | |
| print(" ".join(time_fields)) | |
| elif len(time_fields) == 2: | |
| print(" ".join([*time_fields, *current_fields[2:]])) | |
| else: | |
| raise SystemExit( | |
| f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}" | |
| ) | |
| PY | |
| )" | |
| desired_probe_schedule="$(CURRENT_SCHEDULE="${desired_schedule}" SCHEDULE_TIME="${probe_time}" python - <<'PY' | |
| import os | |
| current_fields = os.environ["CURRENT_SCHEDULE"].split() | |
| time_fields = os.environ["SCHEDULE_TIME"].split() | |
| if len(current_fields) != 5: | |
| raise SystemExit(f"Cloud Scheduler schedule must have 5 fields: {os.environ['CURRENT_SCHEDULE']!r}") | |
| if len(time_fields) == 5: | |
| print(" ".join(time_fields)) | |
| elif len(time_fields) == 2: | |
| print(" ".join([*time_fields, *current_fields[2:]])) | |
| else: | |
| raise SystemExit( | |
| f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}" | |
| ) | |
| PY | |
| )" | |
| probe_job_name="${CLOUD_RUN_SERVICE}-probe-scheduler" | |
| probe_uri="${service_url}/probe" | |
| if gcloud scheduler jobs describe "${probe_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" >/dev/null 2>&1; then | |
| echo "Updating Cloud Scheduler probe ${probe_job_name} to ${desired_probe_schedule}." | |
| gcloud scheduler jobs update http "${probe_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${probe_uri}" \ | |
| --schedule="${desired_probe_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --description="${direct_monitor_description}" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| else | |
| echo "Creating Cloud Scheduler probe ${probe_job_name} at ${desired_probe_schedule}." | |
| gcloud scheduler jobs create http "${probe_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${probe_uri}" \ | |
| --schedule="${desired_probe_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --description="${direct_monitor_description}" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| fi | |
| precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler" | |
| precheck_uri="${service_url}/dry-run" | |
| if gcloud scheduler jobs describe "${precheck_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" >/dev/null 2>&1; then | |
| echo "Updating Cloud Scheduler precheck ${precheck_job_name} to ${desired_precheck_schedule}." | |
| gcloud scheduler jobs update http "${precheck_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${precheck_uri}" \ | |
| --schedule="${desired_precheck_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --description="${direct_monitor_description}" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| else | |
| echo "Creating Cloud Scheduler precheck ${precheck_job_name} at ${desired_precheck_schedule}." | |
| gcloud scheduler jobs create http "${precheck_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${precheck_uri}" \ | |
| --schedule="${desired_precheck_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --description="${direct_monitor_description}" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| fi | |
| managed_scheduler_jobs+=("${probe_job_name}" "${precheck_job_name}") | |
| else | |
| monitor_job_name="schwab-monitor-dispatcher-scheduler" | |
| monitor_uri="${service_url}/monitor-dispatch" | |
| if gcloud scheduler jobs describe "${monitor_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" >/dev/null 2>&1; then | |
| echo "Updating Cloud Scheduler job ${monitor_job_name} to ${monitor_uri}." | |
| gcloud scheduler jobs update http "${monitor_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${monitor_uri}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --schedule="*/5 * * * *" \ | |
| --time-zone="UTC" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| else | |
| echo "Creating Cloud Scheduler job ${monitor_job_name} at ${monitor_uri}." | |
| gcloud scheduler jobs create http "${monitor_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${monitor_uri}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --schedule="*/5 * * * *" \ | |
| --time-zone="UTC" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| fi | |
| fi | |
| for managed_job_name in "${managed_scheduler_jobs[@]}"; do | |
| managed_job_state="$(gcloud scheduler jobs describe "${managed_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --format='value(state)')" | |
| case "${runtime_target_enabled}" in | |
| 1|true|yes|on) | |
| if [ "${managed_job_state}" = "PAUSED" ]; then | |
| echo "Resuming Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is enabled." | |
| gcloud scheduler jobs resume "${managed_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --quiet | |
| fi | |
| ;; | |
| *) | |
| if [ "${managed_job_state}" != "PAUSED" ]; then | |
| echo "Pausing Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is disabled." | |
| gcloud scheduler jobs pause "${managed_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --quiet | |
| fi | |
| ;; | |
| esac | |
| done | |
| if [ "${DIRECT_MONITOR_MIGRATION_COMPLETE:-}" = "true" ]; then | |
| echo "direct_monitors_reconciled=true" >> "${GITHUB_OUTPUT}" | |
| fi | |
| - name: Remove legacy Cloud Scheduler jobs | |
| if: steps.config.outputs.cleanup_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| DIRECT_MONITOR_MIGRATION_COMPLETE: ${{ vars.DIRECT_MONITOR_MIGRATION_COMPLETE }} | |
| DIRECT_MONITOR_CUTOVER_VERIFIED: ${{ vars.DIRECT_MONITOR_CUTOVER_VERIFIED }} | |
| DIRECT_MONITOR_SCHEDULERS_RECONCILED: ${{ steps.scheduler_sync.outputs.direct_monitors_reconciled }} | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/reconcile_cloud_runtime.py cleanup-schedulers | |
| - name: Prune old Cloud Run revisions | |
| if: steps.config.outputs.cleanup_enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| keep_revisions="$( | |
| gcloud run services describe "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --format=json \ | |
| | python -c ' | |
| import json | |
| import sys | |
| service = json.load(sys.stdin) | |
| revisions = { | |
| item.get("revisionName") | |
| for item in service.get("status", {}).get("traffic", []) | |
| if item.get("revisionName") and int(item.get("percent") or 0) > 0 | |
| } | |
| latest_ready = service.get("status", {}).get("latestReadyRevisionName") | |
| if latest_ready: | |
| revisions.add(latest_ready) | |
| for revision in sorted(revisions): | |
| print(revision) | |
| ' | |
| )" | |
| if [ -z "${keep_revisions}" ]; then | |
| echo "No active Cloud Run revision found for ${CLOUD_RUN_SERVICE}; skipping revision prune." >&2 | |
| exit 0 | |
| fi | |
| mapfile -t revisions_to_delete < <( | |
| gcloud run revisions list \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --service "${CLOUD_RUN_SERVICE}" \ | |
| --region "${CLOUD_RUN_REGION}" \ | |
| --format='value(metadata.name)' \ | |
| | grep -Fvx -f <(printf '%s\n' "${keep_revisions}") || true | |
| ) | |
| for revision in "${revisions_to_delete[@]}"; do | |
| echo "Deleting old Cloud Run revision ${revision} for ${CLOUD_RUN_SERVICE}." | |
| gcloud run revisions delete "${revision}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --quiet 2>&1 || true | |
| done | |
| - name: Clean up old Cloud Run images | |
| if: steps.config.outputs.cleanup_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}/charlesschwabplatform/${CLOUD_RUN_SERVICE}" | |
| 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 \ | |
| --async \ | |
| --quiet | |
| done <<< "${old_digests}" |