diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 1481ca2..5bd60f5 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -20,6 +20,8 @@ jobs: ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} + CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }} + CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }} TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }} @@ -168,13 +170,28 @@ jobs: required_vars=( CLOUD_RUN_REGION - CLOUD_RUN_SERVICE ACCOUNT_GROUP IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME GLOBAL_TELEGRAM_CHAT_ID NOTIFY_LANG ) + resolve_cloud_run_services() { + local raw_services="${CLOUD_RUN_SERVICES:-}" + if [ -z "$(printf '%s' "${raw_services}" | tr -d '[:space:],;')" ]; then + raw_services="${CLOUD_RUN_SERVICE:-}" + fi + printf '%s\n' "${raw_services}" \ + | tr ',;' '\n' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \ + | sed '/^$/d' + } + + mapfile -t target_services < <(resolve_cloud_run_services) + if [ "${#target_services[@]}" -eq 0 ]; then + required_vars+=("CLOUD_RUN_SERVICES or CLOUD_RUN_SERVICE") + fi + if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ]; then required_vars+=(IBKR_FEATURE_SNAPSHOT_PATH) fi @@ -232,23 +249,50 @@ jobs: run: | set -euo pipefail + resolve_cloud_run_services() { + local raw_services="${CLOUD_RUN_SERVICES:-}" + if [ -z "$(printf '%s' "${raw_services}" | tr -d '[:space:],;')" ]; then + raw_services="${CLOUD_RUN_SERVICE:-}" + fi + printf '%s\n' "${raw_services}" \ + | tr ',;' '\n' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \ + | sed '/^$/d' + } + + mapfile -t target_services < <(resolve_cloud_run_services) + if [ "${#target_services[@]}" -eq 0 ]; then + echo "CLOUD_RUN_SERVICES or CLOUD_RUN_SERVICE is required." >&2 + exit 1 + fi + + case "${CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT:-true}" in + false|False|FALSE|0|no|No|NO|off|Off|OFF) + echo "Skipping Cloud Run commit wait because CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT is disabled." + exit 0 + ;; + esac + target_sha="${GITHUB_SHA}" - deadline=$((SECONDS + 1800)) - while true; do - deployed_sha="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" --region "${CLOUD_RUN_REGION}" --format='value(spec.template.metadata.labels.commit-sha)' 2>/dev/null || true)" - if [ -n "${deployed_sha}" ] && [ "${deployed_sha}" = "${target_sha}" ]; then - echo "Cloud Run service ${CLOUD_RUN_SERVICE} is on commit ${deployed_sha}." - break - fi + for cloud_run_service in "${target_services[@]}"; do + deadline=$((SECONDS + 1800)) - if [ "${SECONDS}" -ge "${deadline}" ]; then - echo "Timed out waiting for Cloud Run service ${CLOUD_RUN_SERVICE} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 - exit 1 - fi + while true; do + deployed_sha="$(gcloud run services describe "${cloud_run_service}" --region "${CLOUD_RUN_REGION}" --format='value(spec.template.metadata.labels.commit-sha)' 2>/dev/null || true)" + if [ -n "${deployed_sha}" ] && [ "${deployed_sha}" = "${target_sha}" ]; then + echo "Cloud Run service ${cloud_run_service} is on commit ${deployed_sha}." + break + fi - echo "Waiting for Cloud Run service ${CLOUD_RUN_SERVICE} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 - sleep 10 + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "Timed out waiting for Cloud Run service ${cloud_run_service} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 + exit 1 + fi + + echo "Waiting for Cloud Run service ${cloud_run_service} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 + sleep 10 + done done - name: Sync Cloud Run environment @@ -259,6 +303,23 @@ jobs: run: | set -euo pipefail + resolve_cloud_run_services() { + local raw_services="${CLOUD_RUN_SERVICES:-}" + if [ -z "$(printf '%s' "${raw_services}" | tr -d '[:space:],;')" ]; then + raw_services="${CLOUD_RUN_SERVICE:-}" + fi + printf '%s\n' "${raw_services}" \ + | tr ',;' '\n' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \ + | sed '/^$/d' + } + + mapfile -t target_services < <(resolve_cloud_run_services) + if [ "${#target_services[@]}" -eq 0 ]; then + echo "CLOUD_RUN_SERVICES or CLOUD_RUN_SERVICE is required." >&2 + exit 1 + fi + join_by_delimiter() { local delimiter="$1" shift @@ -564,21 +625,23 @@ jobs: remove_env_vars+=("EXECUTION_REPORT_GCS_URI") fi - gcloud_args=( - run services update "${CLOUD_RUN_SERVICE}" - --region "${CLOUD_RUN_REGION}" - --concurrency 1 - --max-instances 1 - --remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")" - --update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")" - ) + for cloud_run_service in "${target_services[@]}"; do + gcloud_args=( + run services update "${cloud_run_service}" + --region "${CLOUD_RUN_REGION}" + --concurrency 1 + --max-instances 1 + --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 [ "${#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 + if [ "${#secret_pairs[@]}" -gt 0 ]; then + gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")") + fi - gcloud "${gcloud_args[@]}" + gcloud "${gcloud_args[@]}" + done diff --git a/README.md b/README.md index 5646731..fa4d60d 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,9 @@ Recommended setup: - **Repository Variables** - `ENABLE_GITHUB_ENV_SYNC` = `true` - `CLOUD_RUN_REGION` - - `CLOUD_RUN_SERVICE` + - `CLOUD_RUN_SERVICES` (comma-, semicolon-, or newline-separated Cloud Run service names; preferred for slot deployments) + - `CLOUD_RUN_SERVICE` (single-service fallback when `CLOUD_RUN_SERVICES` is not set) + - Optional: `CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT=false` if the target services are managed by a deployment flow that does not update the `commit-sha` label before this sync runs - `TELEGRAM_TOKEN_SECRET_NAME` (recommended when Cloud Run already uses Secret Manager for `TELEGRAM_TOKEN`) - `STRATEGY_PROFILE` (set explicitly to one enabled profile, such as `soxl_soxx_trend_income`) - `ACCOUNT_GROUP` (recommended: `paper`) @@ -243,13 +245,14 @@ Recommended setup: - `IB_GATEWAY_ZONE` - `IB_GATEWAY_IP_MODE` -On every push to `main`, the workflow updates the existing Cloud Run service with the values above and removes legacy env vars that should now live in the account-group config (`IB_CLIENT_ID`, `IB_GATEWAY_INSTANCE_NAME`, `IB_GATEWAY_MODE`) plus the older transport vars (`IB_GATEWAY_HOST`, `IB_GATEWAY_PORT`, `TELEGRAM_CHAT_ID`). If `IB_GATEWAY_ZONE` or `IB_GATEWAY_IP_MODE` are blank in GitHub, the workflow also removes them from Cloud Run to avoid drift. +On every push to `main`, the workflow updates the configured Cloud Run service or services with the values above and removes legacy env vars that should now live in the account-group config (`IB_CLIENT_ID`, `IB_GATEWAY_INSTANCE_NAME`, `IB_GATEWAY_MODE`) plus the older transport vars (`IB_GATEWAY_HOST`, `IB_GATEWAY_PORT`, `TELEGRAM_CHAT_ID`). If `IB_GATEWAY_ZONE` or `IB_GATEWAY_IP_MODE` are blank in GitHub, the workflow also removes them from Cloud Run to avoid drift. `STRATEGY_PROFILE` is resolved from a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. The current strategy domain is `us_equity`: `eligible` means the platform can run the strategy in theory, while `enabled` means the current rollout really allows it. `ACCOUNT_GROUP` selects one account-group config entry, and the service fails fast if that runtime identity is incomplete. `RUNTIME_TARGET_JSON` carries the structured runtime identity; strategy defaults continue to come from `UsEquityStrategies`. Important: - The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped. When enabled, it resolves the selected profile's snapshot/config requirements from `scripts/print_strategy_profile_status.py --json` instead of a hard-coded strategy-name list. +- For slot deployments, configure `CLOUD_RUN_SERVICES` instead of the legacy `CLOUD_RUN_SERVICE`. Do not enable the workflow until the GitHub runtime variables describe every selected slot correctly; otherwise one shared `RUNTIME_TARGET_JSON` can overwrite per-slot runtime identity. - Here "shared config" still only means the **IBKR pair** (`InteractiveBrokersPlatform` + `IBKRGatewayManager`). `TELEGRAM_TOKEN` and `TELEGRAM_TOKEN_SECRET_NAME` remain repository-specific. If crisis alert recipients and sender policy are shared across platforms, manage `CRISIS_ALERT_CHANNELS`, `CRISIS_ALERT_EMAIL_*`, and `CRISIS_ALERT_PUSH_*` with GitHub Organization Variables/Secrets or GCP Secret Manager. - If `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` is set, the Cloud Run runtime needs Secret Manager access to that secret. - GitHub now authenticates to Google Cloud with OIDC + Workload Identity Federation, so `GCP_SA_KEY` is no longer required for this workflow. @@ -258,7 +261,7 @@ Important: ### Deployment unit and naming - `QuantPlatformKit` is only a shared dependency; Cloud Run now deploys `InteractiveBrokersPlatform`. -- Recommended Cloud Run service name: `interactive-brokers-quant-service`. +- Recommended single-service Cloud Run name: `interactive-brokers-quant-service`. For slot deployments, keep explicit service names in `CLOUD_RUN_SERVICES`. - For future multi-account rollout, keep one Cloud Run service per `ACCOUNT_GROUP`, and let each service select its account-group config at runtime. - If you later rename or move this repository, reselect the GitHub source in Cloud Build / Cloud Run trigger instead of assuming the existing source binding will update itself. - For the shared deployment model and trigger migration checklist, see [`QuantPlatformKit/docs/deployment_model.md`](../QuantPlatformKit/docs/deployment_model.md). @@ -444,7 +447,9 @@ IB_GATEWAY_IP_MODE=internal - **仓库级 Variables** - `ENABLE_GITHUB_ENV_SYNC` = `true` - `CLOUD_RUN_REGION` - - `CLOUD_RUN_SERVICE` + - `CLOUD_RUN_SERVICES`(逗号、分号或换行分隔的 Cloud Run 服务名;slot 部署优先用这个) + - `CLOUD_RUN_SERVICE`(没设置 `CLOUD_RUN_SERVICES` 时的单服务兼容入口) + - 可选:`CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT=false`(当目标服务由另一个部署链路管理、不会在同步前更新 `commit-sha` label 时使用) - `TELEGRAM_TOKEN_SECRET_NAME`(如果 Cloud Run 上的 `TELEGRAM_TOKEN` 已经改成 Secret Manager,建议配置) - `STRATEGY_PROFILE`(显式设置为任一已启用 profile,例如 `soxl_soxx_trend_income`) - `ACCOUNT_GROUP`(建议设为 `paper`) @@ -461,13 +466,14 @@ IB_GATEWAY_IP_MODE=internal - `IB_GATEWAY_ZONE` - `IB_GATEWAY_IP_MODE` -每次 push 到 `main` 时,这个 workflow 会把上面这些值同步到现有 Cloud Run 服务里,并清掉已经转移到账号组配置里的旧 env(`IB_CLIENT_ID`、`IB_GATEWAY_INSTANCE_NAME`、`IB_GATEWAY_MODE`)以及更早的传输层 env(`IB_GATEWAY_HOST`、`IB_GATEWAY_PORT`、`TELEGRAM_CHAT_ID`)。如果 GitHub 里没有配置 `IB_GATEWAY_ZONE` 或 `IB_GATEWAY_IP_MODE`,workflow 也会把 Cloud Run 上这两个旧值一起删除,避免双配置源漂移。 +每次 push 到 `main` 时,这个 workflow 会把上面这些值同步到配置的一个或多个 Cloud Run 服务里,并清掉已经转移到账号组配置里的旧 env(`IB_CLIENT_ID`、`IB_GATEWAY_INSTANCE_NAME`、`IB_GATEWAY_MODE`)以及更早的传输层 env(`IB_GATEWAY_HOST`、`IB_GATEWAY_PORT`、`TELEGRAM_CHAT_ID`)。如果 GitHub 里没有配置 `IB_GATEWAY_ZONE` 或 `IB_GATEWAY_IP_MODE`,workflow 也会把 Cloud Run 上这两个旧值一起删除,避免双配置源漂移。 `STRATEGY_PROFILE` 由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前策略域仍是 `us_equity`:`eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。 注意: - 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过。打开后,它会通过 `scripts/print_strategy_profile_status.py --json` 动态解析目标策略需要的 snapshot/config 输入,不再维护硬编码策略名列表。 +- slot 部署应配置 `CLOUD_RUN_SERVICES`,不要继续依赖旧的 `CLOUD_RUN_SERVICE`。在 GitHub runtime 变量能准确描述每个 slot 前,不要打开全量同步;否则一份共享的 `RUNTIME_TARGET_JSON` 会覆盖各 slot 自己的运行身份。 - 这里说的“共享配置”仍然只针对 **IBKR 这一组系统**。`TELEGRAM_TOKEN` 和 `TELEGRAM_TOKEN_SECRET_NAME` 都还是这个仓库自己的配置,不建议提升成所有 quant 共用的全局配置。危机告警如果确实跨平台共用同一套收件人和发送方,可以用 GitHub Organization Variables/Secrets 管理 `CRISIS_ALERT_CHANNELS`、`CRISIS_ALERT_EMAIL_*` 和 `CRISIS_ALERT_PUSH_*`。 - 如果设置了 `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`,Cloud Run 运行时还需要有对应 Secret 的访问权限。 - GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。 diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 4df8c6f..5b354ae 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -26,11 +26,16 @@ grep -Fq 'strategy_profile=' "$workflow_file" grep -Fq 'runtime_target_json=' "$workflow_file" grep -Fq 'Wait for Cloud Run deployment of current commit' "$workflow_file" grep -Fq 'target_sha="${GITHUB_SHA}"' "$workflow_file" -grep -Fq "gcloud run services describe \"\${CLOUD_RUN_SERVICE}\" --region \"\${CLOUD_RUN_REGION}\" --format='value(spec.template.metadata.labels.commit-sha)'" "$workflow_file" -grep -Fq 'Timed out waiting for Cloud Run service ${CLOUD_RUN_SERVICE} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}' "$workflow_file" +grep -Fq 'mapfile -t target_services < <(resolve_cloud_run_services)' "$workflow_file" +grep -Fq 'CLOUD_RUN_SERVICES or CLOUD_RUN_SERVICE' "$workflow_file" +grep -Fq 'Skipping Cloud Run commit wait because CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT is disabled.' "$workflow_file" +grep -Fq "gcloud run services describe \"\${cloud_run_service}\" --region \"\${CLOUD_RUN_REGION}\" --format='value(spec.template.metadata.labels.commit-sha)'" "$workflow_file" +grep -Fq 'Timed out waiting for Cloud Run service ${cloud_run_service} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}' "$workflow_file" grep -Fq 'ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }}' "$workflow_file" grep -Fq 'CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}' "$workflow_file" grep -Fq 'CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}' "$workflow_file" +grep -Fq 'CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }}' "$workflow_file" +grep -Fq 'CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }}' "$workflow_file" grep -Fq 'TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}' "$workflow_file" grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}' "$workflow_file" grep -Fq 'ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }}' "$workflow_file" @@ -193,6 +198,7 @@ grep -Fq '"IB_CLIENT_ID"' "$workflow_file" grep -Fq '"IB_GATEWAY_INSTANCE_NAME"' "$workflow_file" grep -Fq '"IB_GATEWAY_MODE"' "$workflow_file" grep -Fq 'join_by_delimiter()' "$workflow_file" +grep -Fq 'run services update "${cloud_run_service}"' "$workflow_file" grep -Fq -- '--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")"' "$workflow_file" grep -Fq -- '--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")"' "$workflow_file" grep -Fq -- '--concurrency 1' "$workflow_file"