diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 5bd60f5..1a15d9a 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -21,6 +21,7 @@ jobs: CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }} + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }} 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 }} @@ -102,116 +103,29 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt - - name: Resolve selected strategy runtime requirements + - name: Resolve Cloud Run sync targets id: strategy_requirements if: steps.config.outputs.enabled == 'true' run: | set -euo pipefail - python - <<'PY' - import json - import os - import subprocess - import sys - from us_equity_strategies import resolve_canonical_profile - - raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip() - if not raw_runtime_target: - raise SystemExit("RUNTIME_TARGET_JSON is required") - runtime_target = json.loads(raw_runtime_target) - profile = str(runtime_target.get("strategy_profile") or "").strip().lower() - if not profile: - raise SystemExit("RUNTIME_TARGET_JSON.strategy_profile is required") - canonical_profile = resolve_canonical_profile(profile) - - raw_status = subprocess.check_output( - [sys.executable, "scripts/print_strategy_profile_status.py", "--json"], - text=True, - ) - rows = json.loads(raw_status) - selected = next((row for row in rows if row["canonical_profile"] == canonical_profile), None) - if selected is None: - supported = ", ".join(sorted(row["canonical_profile"] for row in rows)) - raise SystemExit(f"Unsupported STRATEGY_PROFILE={profile!r}; supported: {supported}") - if not selected.get("eligible") or not selected.get("enabled"): - raise SystemExit(f"STRATEGY_PROFILE={profile!r} is not eligible/enabled: {selected}") - - output_path = os.environ["GITHUB_OUTPUT"] - with open(output_path, "a", encoding="utf-8") as output: - output.write( - f"requires_snapshot_artifacts={str(bool(selected.get('requires_snapshot_artifacts'))).lower()}\n" - ) - output.write( - f"requires_snapshot_manifest_path={str(bool(selected.get('requires_snapshot_manifest_path'))).lower()}\n" - ) - output.write( - f"requires_strategy_config_path={str(bool(selected.get('requires_strategy_config_path'))).lower()}\n" - ) - output.write( - f"config_source_policy={str(selected.get('config_source_policy') or 'none')}\n" - ) - output.write( - f"reconciliation_output_policy={str(selected.get('reconciliation_output_policy') or 'none')}\n" - ) - output.write(f"strategy_profile={canonical_profile}\n") - output.write(f"runtime_target_json={json.dumps(runtime_target, sort_keys=True)}\n") - PY + sync_plan_json="$(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.enabled == 'true' env: - REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }} - REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }} - REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }} - CONFIG_SOURCE_POLICY: ${{ steps.strategy_requirements.outputs.config_source_policy }} - RECONCILIATION_OUTPUT_POLICY: ${{ steps.strategy_requirements.outputs.reconciliation_output_policy }} - RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }} + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} run: | set -euo pipefail required_vars=( CLOUD_RUN_REGION - 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 - - if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ]; then - required_vars+=(IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH) - fi - - if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] && [ "${CONFIG_SOURCE_POLICY:-}" = "env_only" ]; then - required_vars+=(IBKR_STRATEGY_CONFIG_PATH) - fi - - if [ "${RECONCILIATION_OUTPUT_POLICY:-}" = "required" ]; then - required_vars+=(IBKR_RECONCILIATION_OUTPUT_PATH) - fi - - if [ -z "${RUNTIME_TARGET_JSON:-}" ]; then - required_vars+=(RUNTIME_TARGET_JSON) - fi - missing_vars=() for var_name in "${required_vars[@]}"; do if [ -z "${!var_name:-}" ]; then @@ -223,6 +137,21 @@ jobs: missing_vars+=("TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN") fi + python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + targets = plan.get("targets") or [] + if not targets: + raise SystemExit("Cloud Run env sync did not resolve any targets") + for target in targets: + service_name = str(target.get("service_name") or "").strip() + if not service_name: + raise SystemExit("Cloud Run sync target is missing 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 @@ -246,23 +175,21 @@ jobs: - name: Wait for Cloud Run deployment of current commit if: steps.config.outputs.enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} 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) + 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"]) + PY + ) if [ "${#target_services[@]}" -eq 0 ]; then - echo "CLOUD_RUN_SERVICES or CLOUD_RUN_SERVICE is required." >&2 + echo "Cloud Run env sync did not resolve any targets." >&2 exit 1 fi @@ -298,28 +225,47 @@ jobs: - name: Sync Cloud Run environment if: steps.config.outputs.enabled == 'true' env: - RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }} - STRATEGY_PROFILE: ${{ steps.strategy_requirements.outputs.strategy_profile }} + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} 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) + 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"]) + PY + ) if [ "${#target_services[@]}" -eq 0 ]; then - echo "CLOUD_RUN_SERVICES or CLOUD_RUN_SERVICE is required." >&2 + echo "Cloud Run env sync did not resolve any targets." >&2 exit 1 fi + emit_target_env_pairs() { + local target_index="$1" + SYNC_TARGET_INDEX="${target_index}" python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] + for key, value in sorted(target["env"].items()): + print(f"{key}={value}") + PY + } + + emit_target_remove_env_vars() { + local target_index="$1" + SYNC_TARGET_INDEX="${target_index}" python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] + for key in sorted(target.get("remove_env_vars") or []): + print(key) + PY + } + join_by_delimiter() { local delimiter="$1" shift @@ -335,17 +281,10 @@ jobs: printf '%s' "${output}" } - env_pairs=( - "GLOBAL_TELEGRAM_CHAT_ID=${GLOBAL_TELEGRAM_CHAT_ID}" - "NOTIFY_LANG=${NOTIFY_LANG}" - "ACCOUNT_GROUP=${ACCOUNT_GROUP}" - "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=${IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME}" - "STRATEGY_PROFILE=${STRATEGY_PROFILE}" - "RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}" - ) - secret_pairs=() + shared_env_pairs=() + shared_secret_pairs=() - remove_env_vars=( + shared_remove_env_vars=( "IB_GATEWAY_HOST" "IB_GATEWAY_PORT" "TELEGRAM_CHAT_ID" @@ -370,262 +309,192 @@ jobs: "CRISIS_ALERT_SMTP_STARTTLS" "CRISIS_ALERT_SMTP_SSL" ) - remove_secret_vars=( + shared_remove_secret_vars=( "CRISIS_ALERT_SMTP_PASSWORD" "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" "CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD" ) if [ -n "${TELEGRAM_TOKEN_SECRET_NAME:-}" ]; then - secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest") + shared_secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest") else - env_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN}") - remove_secret_vars+=("TELEGRAM_TOKEN") + shared_env_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN}") + shared_remove_secret_vars+=("TELEGRAM_TOKEN") fi if [ -n "${CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME:-}" ]; then - secret_pairs+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD=${CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest") - remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") + shared_secret_pairs+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD=${CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest") + shared_remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") elif [ -n "${CRISIS_ALERT_EMAIL_SENDER_PASSWORD:-}" ]; then - env_pairs+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD=${CRISIS_ALERT_EMAIL_SENDER_PASSWORD}") - remove_secret_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") + shared_env_pairs+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD=${CRISIS_ALERT_EMAIL_SENDER_PASSWORD}") + shared_remove_secret_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") else - remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") - remove_secret_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") + shared_remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") + shared_remove_secret_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD") fi if [ -n "${CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME:-}" ]; then - secret_pairs+=("CRISIS_ALERT_SMS_AUTH_TOKEN=${CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME}:latest") - remove_env_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") + shared_secret_pairs+=("CRISIS_ALERT_SMS_AUTH_TOKEN=${CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME}:latest") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") elif [ -n "${CRISIS_ALERT_SMS_AUTH_TOKEN:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_AUTH_TOKEN=${CRISIS_ALERT_SMS_AUTH_TOKEN}") - remove_secret_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") + shared_env_pairs+=("CRISIS_ALERT_SMS_AUTH_TOKEN=${CRISIS_ALERT_SMS_AUTH_TOKEN}") + shared_remove_secret_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") else - remove_env_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") - remove_secret_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") + shared_remove_secret_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN") fi if [ -n "${CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME:-}" ]; then - secret_pairs+=("CRISIS_ALERT_PUSH_APP_TOKEN=${CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest") - remove_env_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") + shared_secret_pairs+=("CRISIS_ALERT_PUSH_APP_TOKEN=${CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") elif [ -n "${CRISIS_ALERT_PUSH_APP_TOKEN:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_APP_TOKEN=${CRISIS_ALERT_PUSH_APP_TOKEN}") - remove_secret_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") + shared_env_pairs+=("CRISIS_ALERT_PUSH_APP_TOKEN=${CRISIS_ALERT_PUSH_APP_TOKEN}") + shared_remove_secret_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") else - remove_env_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") - remove_secret_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") + shared_remove_secret_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN") fi if [ -n "${CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME:-}" ]; then - secret_pairs+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN=${CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest") - remove_env_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") + shared_secret_pairs+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN=${CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") elif [ -n "${CRISIS_ALERT_PUSH_ACCESS_TOKEN:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN=${CRISIS_ALERT_PUSH_ACCESS_TOKEN}") - remove_secret_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") - else - remove_env_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") - remove_secret_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") - fi - - if [ -n "${IB_GATEWAY_ZONE:-}" ]; then - env_pairs+=("IB_GATEWAY_ZONE=${IB_GATEWAY_ZONE}") - else - remove_env_vars+=("IB_GATEWAY_ZONE") - fi - - if [ -n "${IB_GATEWAY_IP_MODE:-}" ]; then - env_pairs+=("IB_GATEWAY_IP_MODE=${IB_GATEWAY_IP_MODE}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN=${CRISIS_ALERT_PUSH_ACCESS_TOKEN}") + shared_remove_secret_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") else - remove_env_vars+=("IB_GATEWAY_IP_MODE") - fi - - if [ -n "${IBKR_FEATURE_SNAPSHOT_PATH:-}" ]; then - env_pairs+=("IBKR_FEATURE_SNAPSHOT_PATH=${IBKR_FEATURE_SNAPSHOT_PATH}") - else - remove_env_vars+=("IBKR_FEATURE_SNAPSHOT_PATH") - fi - - if [ -n "${IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then - env_pairs+=("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=${IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH}") - else - remove_env_vars+=("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH") - fi - - if [ -n "${IBKR_STRATEGY_CONFIG_PATH:-}" ]; then - env_pairs+=("IBKR_STRATEGY_CONFIG_PATH=${IBKR_STRATEGY_CONFIG_PATH}") - else - remove_env_vars+=("IBKR_STRATEGY_CONFIG_PATH") - fi - - if [ -n "${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON:-}" ]; then - env_pairs+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON=${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON}") - else - remove_env_vars+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON") - fi - - if [ -n "${IBKR_RECONCILIATION_OUTPUT_PATH:-}" ]; then - env_pairs+=("IBKR_RECONCILIATION_OUTPUT_PATH=${IBKR_RECONCILIATION_OUTPUT_PATH}") - else - remove_env_vars+=("IBKR_RECONCILIATION_OUTPUT_PATH") - fi - - if [ -n "${IBKR_DRY_RUN_ONLY:-}" ]; then - env_pairs+=("IBKR_DRY_RUN_ONLY=${IBKR_DRY_RUN_ONLY}") - else - remove_env_vars+=("IBKR_DRY_RUN_ONLY") - fi - - if [ -n "${IBKR_PAPER_LIQUIDATE_ONLY:-}" ]; then - env_pairs+=("IBKR_PAPER_LIQUIDATE_ONLY=${IBKR_PAPER_LIQUIDATE_ONLY}") - else - remove_env_vars+=("IBKR_PAPER_LIQUIDATE_ONLY") - fi - - if [ -n "${IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD:-}" ]; then - env_pairs+=("IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=${IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD}") - else - remove_env_vars+=("IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD") - fi - - if [ -n "${IBKR_MIN_RESERVED_CASH_USD:-}" ]; then - env_pairs+=("IBKR_MIN_RESERVED_CASH_USD=${IBKR_MIN_RESERVED_CASH_USD}") - else - remove_env_vars+=("IBKR_MIN_RESERVED_CASH_USD") - fi - - if [ -n "${IBKR_RESERVED_CASH_RATIO:-}" ]; then - env_pairs+=("IBKR_RESERVED_CASH_RATIO=${IBKR_RESERVED_CASH_RATIO}") - else - remove_env_vars+=("IBKR_RESERVED_CASH_RATIO") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") + shared_remove_secret_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN") fi if [ -n "${CRISIS_ALERT_CHANNELS:-}" ]; then - env_pairs+=("CRISIS_ALERT_CHANNELS=${CRISIS_ALERT_CHANNELS}") + shared_env_pairs+=("CRISIS_ALERT_CHANNELS=${CRISIS_ALERT_CHANNELS}") else - remove_env_vars+=("CRISIS_ALERT_CHANNELS") + shared_remove_env_vars+=("CRISIS_ALERT_CHANNELS") fi if [ -n "${CRISIS_ALERT_EMAIL_RECIPIENTS:-}" ]; then - env_pairs+=("CRISIS_ALERT_EMAIL_RECIPIENTS=${CRISIS_ALERT_EMAIL_RECIPIENTS}") + shared_env_pairs+=("CRISIS_ALERT_EMAIL_RECIPIENTS=${CRISIS_ALERT_EMAIL_RECIPIENTS}") else - remove_env_vars+=("CRISIS_ALERT_EMAIL_RECIPIENTS") + shared_remove_env_vars+=("CRISIS_ALERT_EMAIL_RECIPIENTS") fi if [ -n "${CRISIS_ALERT_EMAIL_SENDER_EMAIL:-}" ]; then - env_pairs+=("CRISIS_ALERT_EMAIL_SENDER_EMAIL=${CRISIS_ALERT_EMAIL_SENDER_EMAIL}") + shared_env_pairs+=("CRISIS_ALERT_EMAIL_SENDER_EMAIL=${CRISIS_ALERT_EMAIL_SENDER_EMAIL}") else - remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_EMAIL") + shared_remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_EMAIL") fi if [ -n "${CRISIS_ALERT_EMAIL_SMTP_HOST:-}" ]; then - env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_HOST=${CRISIS_ALERT_EMAIL_SMTP_HOST}") + shared_env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_HOST=${CRISIS_ALERT_EMAIL_SMTP_HOST}") else - remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_HOST") + shared_remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_HOST") fi if [ -n "${CRISIS_ALERT_EMAIL_SMTP_PORT:-}" ]; then - env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_PORT=${CRISIS_ALERT_EMAIL_SMTP_PORT}") + shared_env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_PORT=${CRISIS_ALERT_EMAIL_SMTP_PORT}") else - remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_PORT") + shared_remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_PORT") fi if [ -n "${CRISIS_ALERT_EMAIL_SMTP_SECURITY:-}" ]; then - env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_SECURITY=${CRISIS_ALERT_EMAIL_SMTP_SECURITY}") + shared_env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_SECURITY=${CRISIS_ALERT_EMAIL_SMTP_SECURITY}") else - remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_SECURITY") + shared_remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_SECURITY") fi if [ -n "${CRISIS_ALERT_SMS_RECIPIENTS:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_RECIPIENTS=${CRISIS_ALERT_SMS_RECIPIENTS}") + shared_env_pairs+=("CRISIS_ALERT_SMS_RECIPIENTS=${CRISIS_ALERT_SMS_RECIPIENTS}") else - remove_env_vars+=("CRISIS_ALERT_SMS_RECIPIENTS") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_RECIPIENTS") fi if [ -n "${CRISIS_ALERT_SMS_PROVIDER:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_PROVIDER=${CRISIS_ALERT_SMS_PROVIDER}") + shared_env_pairs+=("CRISIS_ALERT_SMS_PROVIDER=${CRISIS_ALERT_SMS_PROVIDER}") else - remove_env_vars+=("CRISIS_ALERT_SMS_PROVIDER") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_PROVIDER") fi if [ -n "${CRISIS_ALERT_SMS_ACCOUNT_ID:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_ACCOUNT_ID=${CRISIS_ALERT_SMS_ACCOUNT_ID}") + shared_env_pairs+=("CRISIS_ALERT_SMS_ACCOUNT_ID=${CRISIS_ALERT_SMS_ACCOUNT_ID}") else - remove_env_vars+=("CRISIS_ALERT_SMS_ACCOUNT_ID") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_ACCOUNT_ID") fi if [ -n "${CRISIS_ALERT_SMS_SENDER:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_SENDER=${CRISIS_ALERT_SMS_SENDER}") + shared_env_pairs+=("CRISIS_ALERT_SMS_SENDER=${CRISIS_ALERT_SMS_SENDER}") else - remove_env_vars+=("CRISIS_ALERT_SMS_SENDER") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_SENDER") fi if [ -n "${CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID=${CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID}") + shared_env_pairs+=("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID=${CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID}") else - remove_env_vars+=("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID") fi if [ -n "${CRISIS_ALERT_SMS_API_BASE_URL:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_API_BASE_URL=${CRISIS_ALERT_SMS_API_BASE_URL}") + shared_env_pairs+=("CRISIS_ALERT_SMS_API_BASE_URL=${CRISIS_ALERT_SMS_API_BASE_URL}") else - remove_env_vars+=("CRISIS_ALERT_SMS_API_BASE_URL") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_API_BASE_URL") fi if [ -n "${CRISIS_ALERT_SMS_BODY_MAX_CHARS:-}" ]; then - env_pairs+=("CRISIS_ALERT_SMS_BODY_MAX_CHARS=${CRISIS_ALERT_SMS_BODY_MAX_CHARS}") + shared_env_pairs+=("CRISIS_ALERT_SMS_BODY_MAX_CHARS=${CRISIS_ALERT_SMS_BODY_MAX_CHARS}") else - remove_env_vars+=("CRISIS_ALERT_SMS_BODY_MAX_CHARS") + shared_remove_env_vars+=("CRISIS_ALERT_SMS_BODY_MAX_CHARS") fi if [ -n "${CRISIS_ALERT_PUSH_RECIPIENTS:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_RECIPIENTS=${CRISIS_ALERT_PUSH_RECIPIENTS}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_RECIPIENTS=${CRISIS_ALERT_PUSH_RECIPIENTS}") else - remove_env_vars+=("CRISIS_ALERT_PUSH_RECIPIENTS") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_RECIPIENTS") fi if [ -n "${CRISIS_ALERT_PUSH_PROVIDER:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_PROVIDER=${CRISIS_ALERT_PUSH_PROVIDER}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_PROVIDER=${CRISIS_ALERT_PUSH_PROVIDER}") else - remove_env_vars+=("CRISIS_ALERT_PUSH_PROVIDER") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_PROVIDER") fi if [ -n "${CRISIS_ALERT_PUSH_API_BASE_URL:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_API_BASE_URL=${CRISIS_ALERT_PUSH_API_BASE_URL}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_API_BASE_URL=${CRISIS_ALERT_PUSH_API_BASE_URL}") else - remove_env_vars+=("CRISIS_ALERT_PUSH_API_BASE_URL") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_API_BASE_URL") fi if [ -n "${CRISIS_ALERT_PUSH_DEVICE:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_DEVICE=${CRISIS_ALERT_PUSH_DEVICE}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_DEVICE=${CRISIS_ALERT_PUSH_DEVICE}") else - remove_env_vars+=("CRISIS_ALERT_PUSH_DEVICE") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_DEVICE") fi if [ -n "${CRISIS_ALERT_PUSH_PRIORITY:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_PRIORITY=${CRISIS_ALERT_PUSH_PRIORITY}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_PRIORITY=${CRISIS_ALERT_PUSH_PRIORITY}") else - remove_env_vars+=("CRISIS_ALERT_PUSH_PRIORITY") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_PRIORITY") fi if [ -n "${CRISIS_ALERT_PUSH_TAGS:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_TAGS=${CRISIS_ALERT_PUSH_TAGS}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_TAGS=${CRISIS_ALERT_PUSH_TAGS}") else - remove_env_vars+=("CRISIS_ALERT_PUSH_TAGS") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_TAGS") fi if [ -n "${CRISIS_ALERT_PUSH_BODY_MAX_CHARS:-}" ]; then - env_pairs+=("CRISIS_ALERT_PUSH_BODY_MAX_CHARS=${CRISIS_ALERT_PUSH_BODY_MAX_CHARS}") + shared_env_pairs+=("CRISIS_ALERT_PUSH_BODY_MAX_CHARS=${CRISIS_ALERT_PUSH_BODY_MAX_CHARS}") else - remove_env_vars+=("CRISIS_ALERT_PUSH_BODY_MAX_CHARS") + shared_remove_env_vars+=("CRISIS_ALERT_PUSH_BODY_MAX_CHARS") fi - if [ -n "${EXECUTION_REPORT_GCS_URI:-}" ]; then - env_pairs+=("EXECUTION_REPORT_GCS_URI=${EXECUTION_REPORT_GCS_URI}") - else - remove_env_vars+=("EXECUTION_REPORT_GCS_URI") - fi + for target_index in "${!target_services[@]}"; do + cloud_run_service="${target_services[${target_index}]}" + mapfile -t target_env_pairs < <(emit_target_env_pairs "${target_index}") + mapfile -t target_remove_env_vars < <(emit_target_remove_env_vars "${target_index}") + env_pairs=("${target_env_pairs[@]}" "${shared_env_pairs[@]}") + remove_env_vars=("${target_remove_env_vars[@]}" "${shared_remove_env_vars[@]}") + secret_pairs=("${shared_secret_pairs[@]}") + remove_secret_vars=("${shared_remove_secret_vars[@]}") - for cloud_run_service in "${target_services[@]}"; do gcloud_args=( run services update "${cloud_run_service}" --region "${CLOUD_RUN_REGION}" diff --git a/README.md b/README.md index fa4d60d..ad4305e 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ Recommended setup: - `CLOUD_RUN_REGION` - `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) + - `CLOUD_RUN_SERVICE_TARGETS_JSON` (preferred for full slot sync; see below) - 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`) @@ -245,14 +246,72 @@ Recommended setup: - `IB_GATEWAY_ZONE` - `IB_GATEWAY_IP_MODE` -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. +On every push to `main`, the workflow builds a Cloud Run sync plan, updates the configured Cloud Run service or services, 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 the selected sync target, 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`. +For slot deployments, use `CLOUD_RUN_SERVICE_TARGETS_JSON` instead of a shared `RUNTIME_TARGET_JSON`. This keeps shared alert policy in one place while each Cloud Run service owns its runtime identity: + +```json +{ + "defaults": { + "GLOBAL_TELEGRAM_CHAT_ID": "5992562050", + "NOTIFY_LANG": "zh", + "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME": "ibkr-account-groups", + "IB_GATEWAY_ZONE": "us-central1-c", + "IB_GATEWAY_IP_MODE": "internal", + "EXECUTION_REPORT_GCS_URI": "gs://qsl-runtime-logs-interactivebrokersquant/execution-reports" + }, + "targets": [ + { + "service": "interactive-brokers-live-slot-a-service", + "account_group": "live-slot-a", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "tqqq_growth_income", + "dry_run_only": false, + "deployment_selector": "live-slot-a", + "account_selector": ["U18308207"], + "account_scope": "live-slot-a", + "service_name": "interactive-brokers-live-slot-a-service", + "execution_mode": "live" + }, + "ibkr_strategy_plugin_mounts_json": { + "strategy_plugins": [ + { + "strategy": "tqqq_growth_income", + "plugin": "crisis_response_shadow", + "signal_path": "gs://qsl-runtime-logs-interactivebrokersquant/strategy-artifacts/us_equity/tqqq_growth_income/plugins/crisis_response_shadow/latest_signal.json", + "enabled": true, + "expected_mode": "shadow" + } + ] + } + }, + { + "service": "interactive-brokers-live-slot-b-service", + "account_group": "live-slot-b", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "mega_cap_leader_rotation_top50_balanced", + "dry_run_only": false, + "deployment_selector": "live-slot-b", + "account_selector": ["U15998061"], + "account_scope": "live-slot-b", + "service_name": "interactive-brokers-live-slot-b-service", + "execution_mode": "live" + }, + "ibkr_feature_snapshot_path": "gs://qsl-runtime-logs-interactivebrokersquant/strategy-artifacts/us_equity/mega_cap_leader_rotation_top50_balanced_staging/mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv", + "ibkr_feature_snapshot_manifest_path": "gs://qsl-runtime-logs-interactivebrokersquant/strategy-artifacts/us_equity/mega_cap_leader_rotation_top50_balanced_staging/mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv.manifest.json" + } + ] +} +``` + 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. +- The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped. When enabled, it builds the per-service plan with `scripts/build_cloud_run_env_sync_plan.py` and resolves each selected profile's snapshot/config requirements from the strategy status matrix instead of a hard-coded strategy-name list. +- For full slot sync, configure `CLOUD_RUN_SERVICE_TARGETS_JSON`. `CLOUD_RUN_SERVICES` is only for the legacy mode where every listed service intentionally receives the same runtime env. - 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. @@ -449,6 +508,7 @@ IB_GATEWAY_IP_MODE=internal - `CLOUD_RUN_REGION` - `CLOUD_RUN_SERVICES`(逗号、分号或换行分隔的 Cloud Run 服务名;slot 部署优先用这个) - `CLOUD_RUN_SERVICE`(没设置 `CLOUD_RUN_SERVICES` 时的单服务兼容入口) + - `CLOUD_RUN_SERVICE_TARGETS_JSON`(全量同步 slot 时优先用这个,见下面示例) - 可选:`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`) @@ -466,14 +526,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 sync plan,再把目标值同步到配置的一个或多个 Cloud Run 服务里,并清掉已经转移到账号组配置里的旧 env(`IB_CLIENT_ID`、`IB_GATEWAY_INSTANCE_NAME`、`IB_GATEWAY_MODE`)以及更早的传输层 env(`IB_GATEWAY_HOST`、`IB_GATEWAY_PORT`、`TELEGRAM_CHAT_ID`)。如果目标 sync 配置里没有 `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 自己的运行身份。 +- 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过。打开后,它会用 `scripts/build_cloud_run_env_sync_plan.py` 生成 per-service plan,并从策略状态矩阵动态解析每个目标策略需要的 snapshot/config 输入,不再维护硬编码策略名列表。 +- 全量同步 slot 时应配置 `CLOUD_RUN_SERVICE_TARGETS_JSON`。`CLOUD_RUN_SERVICES` 只适合旧模式,也就是多个服务确实要收到同一份 runtime env。 - 这里说的“共享配置”仍然只针对 **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/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py new file mode 100644 index 0000000..45f2df7 --- /dev/null +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" +UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" + +for candidate in (ROOT, QPK_SRC, UES_SRC): + candidate_str = str(candidate) + if candidate_str not in sys.path: + sys.path.insert(0, candidate_str) + +from strategy_registry import ( # noqa: E402 + IBKR_PLATFORM, + get_platform_profile_status_matrix, + resolve_strategy_definition, +) +from us_equity_strategies.runtime_adapters import describe_platform_runtime_requirements # noqa: E402 + + +TARGETS_JSON_ENV = "CLOUD_RUN_SERVICE_TARGETS_JSON" +SHARED_TARGET_FALLBACK_ENV = frozenset( + { + "GLOBAL_TELEGRAM_CHAT_ID", + "NOTIFY_LANG", + "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", + "IB_GATEWAY_ZONE", + "IB_GATEWAY_IP_MODE", + "EXECUTION_REPORT_GCS_URI", + } +) +REQUIRED_ENV = ( + "GLOBAL_TELEGRAM_CHAT_ID", + "NOTIFY_LANG", + "ACCOUNT_GROUP", + "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", +) +OPTIONAL_TARGET_ENV = ( + "IB_GATEWAY_ZONE", + "IB_GATEWAY_IP_MODE", + "IBKR_FEATURE_SNAPSHOT_PATH", + "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", + "IBKR_STRATEGY_CONFIG_PATH", + "IBKR_STRATEGY_PLUGIN_MOUNTS_JSON", + "IBKR_RECONCILIATION_OUTPUT_PATH", + "IBKR_DRY_RUN_ONLY", + "IBKR_PAPER_LIQUIDATE_ONLY", + "IBKR_MIN_RESERVED_CASH_USD", + "IBKR_RESERVED_CASH_RATIO", + "IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", + "EXECUTION_REPORT_GCS_URI", +) + + +def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]: + target_entries, defaults, per_service_mode = _load_target_entries(env) + status_rows = { + str(row["canonical_profile"]): { + **row, + **describe_platform_runtime_requirements( + str(row["canonical_profile"]), + platform_id=IBKR_PLATFORM, + ), + } + for row in get_platform_profile_status_matrix() + } + planned_targets = [ + _build_target_plan( + target=target, + defaults=defaults, + env=env, + status_rows=status_rows, + per_service_mode=per_service_mode, + ) + for target in target_entries + ] + if not planned_targets: + raise ValueError( + f"{TARGETS_JSON_ENV}, CLOUD_RUN_SERVICES, or CLOUD_RUN_SERVICE is required" + ) + return { + "mode": "per_service" if per_service_mode else "legacy", + "targets": planned_targets, + } + + +def _load_target_entries( + env: Mapping[str, str], +) -> tuple[list[dict[str, object]], Mapping[str, object], bool]: + raw_targets = str(env.get(TARGETS_JSON_ENV, "") or "").strip() + if raw_targets: + payload = json.loads(raw_targets) + if isinstance(payload, Mapping): + raw_entries = payload.get("targets") + defaults = _coerce_mapping(payload.get("defaults") or {}) + else: + raw_entries = payload + defaults = {} + if not isinstance(raw_entries, Sequence) or isinstance(raw_entries, (str, bytes)): + raise ValueError(f"{TARGETS_JSON_ENV} must be a JSON array or object with targets") + entries = [_coerce_mapping(item) for item in raw_entries] + return entries, defaults, True + + raw_services = str(env.get("CLOUD_RUN_SERVICES", "") or "").strip() + if not raw_services: + raw_services = str(env.get("CLOUD_RUN_SERVICE", "") or "").strip() + services = [ + item.strip() + for chunk in raw_services.replace(";", ",").replace("\n", ",").split(",") + for item in [chunk] + if item.strip() + ] + return [{"service": service} for service in services], {}, False + + +def _build_target_plan( + *, + target: Mapping[str, object], + defaults: Mapping[str, object], + env: Mapping[str, str], + status_rows: Mapping[str, Mapping[str, object]], + per_service_mode: bool, +) -> dict[str, object]: + service_name = _first_non_empty( + _target_field(target, defaults, "service"), + _target_field(target, defaults, "service_name"), + _target_field(target, defaults, "cloud_run_service"), + ) + runtime_target = _resolve_runtime_target(target, defaults, env, per_service_mode) + if not service_name: + service_name = str(runtime_target.get("service_name") or "").strip() + if not service_name: + raise ValueError("Each Cloud Run sync target requires service/service_name") + + runtime_target_service = str(runtime_target.get("service_name") or "").strip() + if runtime_target_service and runtime_target_service != service_name: + raise ValueError( + f"Target {service_name} runtime_target.service_name={runtime_target_service!r} does not match" + ) + runtime_target["service_name"] = service_name + + raw_profile = str(runtime_target.get("strategy_profile") or "").strip() + if not raw_profile: + raise ValueError(f"Target {service_name} runtime_target.strategy_profile is required") + definition = resolve_strategy_definition(raw_profile, platform_id=IBKR_PLATFORM) + canonical_profile = definition.profile + runtime_target["strategy_profile"] = canonical_profile + + status = status_rows.get(canonical_profile) + if status is None: + supported = ", ".join(sorted(status_rows)) + raise ValueError( + f"Unsupported STRATEGY_PROFILE={raw_profile!r} for {service_name}; supported: {supported}" + ) + if not status.get("eligible") or not status.get("enabled"): + raise ValueError( + f"STRATEGY_PROFILE={raw_profile!r} is not eligible/enabled for {service_name}: {status}" + ) + + env_values: dict[str, str] = {} + missing: list[str] = [] + for name in REQUIRED_ENV: + value = _target_env_value( + target, + defaults, + env, + name, + per_service_mode=per_service_mode, + allow_shared_fallback=name in SHARED_TARGET_FALLBACK_ENV, + ) + if value is None and name == "ACCOUNT_GROUP": + value = _first_non_empty( + runtime_target.get("account_scope"), + runtime_target.get("deployment_selector"), + ) + if value is None: + missing.append(f"{service_name}:{name}") + else: + env_values[name] = value + + env_values["STRATEGY_PROFILE"] = canonical_profile + env_values["RUNTIME_TARGET_JSON"] = json.dumps( + runtime_target, + separators=(",", ":"), + sort_keys=True, + ) + + remove_env_vars: list[str] = [] + for name in OPTIONAL_TARGET_ENV: + value = _target_env_value( + target, + defaults, + env, + name, + per_service_mode=per_service_mode, + allow_shared_fallback=name in SHARED_TARGET_FALLBACK_ENV, + ) + if value is None and name == "IBKR_DRY_RUN_ONLY": + dry_run_value = runtime_target.get("dry_run_only") + if dry_run_value is not None: + value = _coerce_env_value(dry_run_value) + if value is None: + remove_env_vars.append(name) + else: + env_values[name] = value + + _validate_profile_inputs( + service_name=service_name, + env_values=env_values, + status=status, + missing=missing, + ) + if missing: + raise ValueError( + "Cloud Run env sync target values are missing:\n" + + "\n".join(f" - {item}" for item in missing) + ) + + return { + "service_name": service_name, + "strategy_profile": canonical_profile, + "env": env_values, + "remove_env_vars": sorted(set(remove_env_vars) - set(env_values)), + } + + +def _validate_profile_inputs( + *, + service_name: str, + env_values: Mapping[str, str], + status: Mapping[str, object], + missing: list[str], +) -> None: + if bool(status.get("requires_snapshot_artifacts")) and not env_values.get( + "IBKR_FEATURE_SNAPSHOT_PATH" + ): + missing.append(f"{service_name}:IBKR_FEATURE_SNAPSHOT_PATH") + if bool(status.get("requires_snapshot_manifest_path")) and not env_values.get( + "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH" + ): + missing.append(f"{service_name}:IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH") + if ( + bool(status.get("requires_strategy_config_path")) + and str(status.get("config_source_policy") or "none") == "env_only" + and not env_values.get("IBKR_STRATEGY_CONFIG_PATH") + ): + missing.append(f"{service_name}:IBKR_STRATEGY_CONFIG_PATH") + if str(status.get("reconciliation_output_policy") or "none") == "required" and not env_values.get( + "IBKR_RECONCILIATION_OUTPUT_PATH" + ): + missing.append(f"{service_name}:IBKR_RECONCILIATION_OUTPUT_PATH") + + +def _resolve_runtime_target( + target: Mapping[str, object], + defaults: Mapping[str, object], + env: Mapping[str, str], + per_service_mode: bool, +) -> dict[str, object]: + raw = _first_non_empty( + _target_field(target, defaults, "runtime_target"), + _target_field(target, defaults, "runtime_target_json"), + ) + if raw is None and not per_service_mode: + raw = str(env.get("RUNTIME_TARGET_JSON", "") or "").strip() or None + if raw is None: + raise ValueError( + f"{TARGETS_JSON_ENV}.targets[].runtime_target is required in per-service mode" + ) + if isinstance(raw, Mapping): + return dict(raw) + if isinstance(raw, str): + loaded = json.loads(raw) + if not isinstance(loaded, Mapping): + raise ValueError("runtime_target_json must decode to a JSON object") + return dict(loaded) + raise ValueError("runtime_target must be a JSON object or JSON object string") + + +def _target_env_value( + target: Mapping[str, object], + defaults: Mapping[str, object], + env: Mapping[str, str], + env_name: str, + *, + per_service_mode: bool, + allow_shared_fallback: bool, +) -> str | None: + raw = _first_non_empty( + _target_field(target, defaults, env_name), + _target_field(target, defaults, env_name.lower()), + ) + if raw is None and (not per_service_mode or allow_shared_fallback): + raw = str(env.get(env_name, "") or "").strip() or None + return _coerce_env_value(raw) + + +def _target_field( + target: Mapping[str, object], + defaults: Mapping[str, object], + name: str, +) -> object | None: + for source in (target, _coerce_mapping(target.get("env") or {}), defaults, _coerce_mapping(defaults.get("env") or {})): + if name in source: + return source[name] + return None + + +def _coerce_mapping(value: object) -> Mapping[str, object]: + if not isinstance(value, Mapping): + raise ValueError("Expected a JSON object") + return value + + +def _coerce_env_value(value: object) -> str | None: + if value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (Mapping, list, tuple)): + return json.dumps(value, separators=(",", ":"), sort_keys=True) + text = str(value).strip() + return text or None + + +def _first_non_empty(*values: object | None) -> object | None: + for value in values: + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + return value + return None + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--json", action="store_true", help="Print compact JSON.") + args = parser.parse_args() + + try: + plan = build_sync_plan() + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + if args.json: + print(json.dumps(plan, separators=(",", ":"), sort_keys=True)) + else: + print(json.dumps(plan, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 1ce552c..fb39696 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1,4 +1,5 @@ import json +import os import subprocess import sys from pathlib import Path @@ -29,6 +30,7 @@ ) SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "print_strategy_profile_status.py" SWITCH_PLAN_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "print_strategy_switch_env_plan.py" +SYNC_PLAN_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "build_cloud_run_env_sync_plan.py" SAMPLE_STRATEGY_PROFILE = "global_etf_rotation" @@ -583,6 +585,137 @@ def test_print_strategy_switch_env_plan_for_tqqq_growth_income(): assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"] +def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): + payload = { + "defaults": { + "GLOBAL_TELEGRAM_CHAT_ID": "5992562050", + "NOTIFY_LANG": "zh", + "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME": "ibkr-account-groups", + "IB_GATEWAY_ZONE": "us-central1-c", + "IB_GATEWAY_IP_MODE": "internal", + "EXECUTION_REPORT_GCS_URI": "gs://runtime/execution-reports", + }, + "targets": [ + { + "service": "interactive-brokers-live-slot-a-service", + "account_group": "live-slot-a", + "runtime_target": json.loads( + runtime_target_json( + "tqqq_growth_income", + deployment_selector="live-slot-a", + account_selector=["U18308207"], + account_scope="live-slot-a", + service_name="interactive-brokers-live-slot-a-service", + ) + ), + "ibkr_strategy_plugin_mounts_json": { + "strategy_plugins": [ + { + "strategy": "tqqq_growth_income", + "plugin": "crisis_response_shadow", + "signal_path": "gs://runtime/tqqq/latest_signal.json", + "enabled": True, + "expected_mode": "shadow", + } + ] + }, + }, + { + "service": "interactive-brokers-live-slot-b-service", + "account_group": "live-slot-b", + "runtime_target": json.loads( + runtime_target_json( + "mega_cap_leader_rotation_top50_balanced", + deployment_selector="live-slot-b", + account_selector=["U15998061"], + account_scope="live-slot-b", + service_name="interactive-brokers-live-slot-b-service", + ) + ), + "ibkr_feature_snapshot_path": "gs://runtime/mega/snapshot.csv", + "ibkr_feature_snapshot_manifest_path": "gs://runtime/mega/snapshot.csv.manifest.json", + }, + ], + } + env = { + **os.environ, + "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload), + "IBKR_FEATURE_SNAPSHOT_PATH": "gs://stale-paper/snapshot.csv", + } + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + check=True, + capture_output=True, + text=True, + env=env, + ) + + plan = json.loads(result.stdout) + assert plan["mode"] == "per_service" + by_service = {target["service_name"]: target for target in plan["targets"]} + slot_a = by_service["interactive-brokers-live-slot-a-service"] + slot_b = by_service["interactive-brokers-live-slot-b-service"] + + assert slot_a["env"]["ACCOUNT_GROUP"] == "live-slot-a" + assert slot_a["env"]["STRATEGY_PROFILE"] == "tqqq_growth_income" + assert "IBKR_FEATURE_SNAPSHOT_PATH" not in slot_a["env"] + assert "IBKR_FEATURE_SNAPSHOT_PATH" in slot_a["remove_env_vars"] + assert "gs://stale-paper/snapshot.csv" not in json.dumps(slot_a) + assert json.loads(slot_a["env"]["IBKR_STRATEGY_PLUGIN_MOUNTS_JSON"])["strategy_plugins"][0][ + "strategy" + ] == "tqqq_growth_income" + + assert slot_b["env"]["ACCOUNT_GROUP"] == "live-slot-b" + assert slot_b["env"]["STRATEGY_PROFILE"] == "mega_cap_leader_rotation_top50_balanced" + assert slot_b["env"]["IBKR_FEATURE_SNAPSHOT_PATH"] == "gs://runtime/mega/snapshot.csv" + assert ( + slot_b["env"]["IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"] + == "gs://runtime/mega/snapshot.csv.manifest.json" + ) + + +def test_build_cloud_run_env_sync_plan_requires_target_snapshot_in_per_service_mode(): + payload = { + "defaults": { + "GLOBAL_TELEGRAM_CHAT_ID": "5992562050", + "NOTIFY_LANG": "zh", + "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME": "ibkr-account-groups", + }, + "targets": [ + { + "service": "interactive-brokers-live-slot-b-service", + "account_group": "live-slot-b", + "runtime_target": json.loads( + runtime_target_json( + "mega_cap_leader_rotation_top50_balanced", + deployment_selector="live-slot-b", + account_selector=["U15998061"], + account_scope="live-slot-b", + service_name="interactive-brokers-live-slot-b-service", + ) + ), + } + ], + } + env = { + **os.environ, + "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload), + "IBKR_FEATURE_SNAPSHOT_PATH": "gs://stale-paper/snapshot.csv", + } + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert "interactive-brokers-live-slot-b-service:IBKR_FEATURE_SNAPSHOT_PATH" in result.stderr + assert "gs://stale-paper/snapshot.csv" not in result.stderr + + def test_print_strategy_switch_env_plan_rejects_removed_research_profile(): result = subprocess.run( [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "mega_cap_leader_rotation_dynamic_top20", "--json"], diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 5b354ae..1a787e0 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -13,195 +13,62 @@ grep -Fq 'service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}' "$w grep -Fq 'uses: actions/checkout@v6' "$workflow_file" grep -Fq 'uses: actions/setup-python@v6' "$workflow_file" grep -Fq 'python -m pip install -r requirements.txt' "$workflow_file" -grep -Fq 'id: strategy_requirements' "$workflow_file" -grep -Fq 'scripts/print_strategy_profile_status.py' "$workflow_file" -grep -Fq 'from us_equity_strategies import resolve_canonical_profile' "$workflow_file" -grep -Fq 'canonical_profile = resolve_canonical_profile(profile)' "$workflow_file" -grep -Fq 'requires_snapshot_artifacts=' "$workflow_file" -grep -Fq 'requires_snapshot_manifest_path=' "$workflow_file" -grep -Fq 'requires_strategy_config_path=' "$workflow_file" -grep -Fq 'config_source_policy=' "$workflow_file" -grep -Fq 'reconciliation_output_policy=' "$workflow_file" -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 '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_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }}' "$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" grep -Fq 'IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }}' "$workflow_file" -grep -Fq 'IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }}' "$workflow_file" -grep -Fq 'IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }}' "$workflow_file" -grep -Fq 'IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }}' "$workflow_file" grep -Fq 'IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }}' "$workflow_file" -grep -Fq 'IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }}' "$workflow_file" -grep -Fq 'IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }}' "$workflow_file" -grep -Fq 'IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }}' "$workflow_file" -grep -Fq 'IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }}' "$workflow_file" -grep -Fq 'IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }}' "$workflow_file" -grep -Fq 'IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}' "$workflow_file" grep -Fq 'CRISIS_ALERT_CHANNELS: ${{ vars.CRISIS_ALERT_CHANNELS }}' "$workflow_file" grep -Fq 'CRISIS_ALERT_EMAIL_RECIPIENTS: ${{ vars.CRISIS_ALERT_EMAIL_RECIPIENTS }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.CRISIS_ALERT_EMAIL_SENDER_EMAIL }}' "$workflow_file" grep -Fq 'CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME: ${{ vars.CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_EMAIL_SMTP_HOST: ${{ vars.CRISIS_ALERT_EMAIL_SMTP_HOST }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_EMAIL_SMTP_PORT: ${{ vars.CRISIS_ALERT_EMAIL_SMTP_PORT }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_EMAIL_SMTP_SECURITY: ${{ vars.CRISIS_ALERT_EMAIL_SMTP_SECURITY }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_RECIPIENTS: ${{ vars.CRISIS_ALERT_SMS_RECIPIENTS }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_PROVIDER: ${{ vars.CRISIS_ALERT_SMS_PROVIDER }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_ACCOUNT_ID: ${{ vars.CRISIS_ALERT_SMS_ACCOUNT_ID }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: ${{ vars.CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_SENDER: ${{ vars.CRISIS_ALERT_SMS_SENDER }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_API_BASE_URL: ${{ vars.CRISIS_ALERT_SMS_API_BASE_URL }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.CRISIS_ALERT_SMS_BODY_MAX_CHARS }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_RECIPIENTS: ${{ vars.CRISIS_ALERT_PUSH_RECIPIENTS }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_PROVIDER: ${{ vars.CRISIS_ALERT_PUSH_PROVIDER }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME: ${{ vars.CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME: ${{ vars.CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_API_BASE_URL: ${{ vars.CRISIS_ALERT_PUSH_API_BASE_URL }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_DEVICE: ${{ vars.CRISIS_ALERT_PUSH_DEVICE }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_PRIORITY: ${{ vars.CRISIS_ALERT_PUSH_PRIORITY }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_TAGS: ${{ vars.CRISIS_ALERT_PUSH_TAGS }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_BODY_MAX_CHARS: ${{ vars.CRISIS_ALERT_PUSH_BODY_MAX_CHARS }}' "$workflow_file" -grep -Fq 'IB_GATEWAY_ZONE: ${{ vars.IB_GATEWAY_ZONE }}' "$workflow_file" -grep -Fq 'IB_GATEWAY_IP_MODE: ${{ vars.IB_GATEWAY_IP_MODE }}' "$workflow_file" -grep -Fq 'GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}' "$workflow_file" -grep -Fq 'NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}' "$workflow_file" grep -Fq 'TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}' "$workflow_file" grep -Fq 'CRISIS_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.CRISIS_ALERT_EMAIL_SENDER_PASSWORD }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_SMS_AUTH_TOKEN: ${{ secrets.CRISIS_ALERT_SMS_AUTH_TOKEN }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_APP_TOKEN: ${{ secrets.CRISIS_ALERT_PUSH_APP_TOKEN }}' "$workflow_file" -grep -Fq 'CRISIS_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.CRISIS_ALERT_PUSH_ACCESS_TOKEN }}' "$workflow_file" -grep -Fq "Skipping Cloud Run env sync because ENABLE_GITHUB_ENV_SYNC is not set to true." "$workflow_file" -grep -Fq "Cloud Run env sync is enabled, but these values are missing:" "$workflow_file" + +grep -Fq 'Skipping Cloud Run env sync because ENABLE_GITHUB_ENV_SYNC is not set to true.' "$workflow_file" +grep -Fq 'scripts/build_cloud_run_env_sync_plan.py --json' "$workflow_file" +grep -Fq 'sync_plan_json<<__SYNC_PLAN_JSON__' "$workflow_file" +grep -Fq 'SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}' "$workflow_file" +grep -Fq 'Cloud Run env sync did not resolve any targets' "$workflow_file" grep -Fq 'TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN' "$workflow_file" -grep -Fq "if: steps.config.outputs.enabled == 'true'" "$workflow_file" -grep -Fq 'env_pairs=(' "$workflow_file" -grep -Fq 'secret_pairs=(' "$workflow_file" -grep -Fq 'remove_env_vars=(' "$workflow_file" -grep -Fq 'remove_secret_vars=(' "$workflow_file" -grep -Fq 'ACCOUNT_GROUP=${ACCOUNT_GROUP}' "$workflow_file" -grep -Fq 'IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=${IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME}' "$workflow_file" -grep -Fq 'STRATEGY_PROFILE=${STRATEGY_PROFILE}' "$workflow_file" -grep -Fq 'RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}' "$workflow_file" -grep -Fq 'REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }}' "$workflow_file" -grep -Fq 'REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }}' "$workflow_file" -grep -Fq 'REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }}' "$workflow_file" -grep -Fq 'CONFIG_SOURCE_POLICY: ${{ steps.strategy_requirements.outputs.config_source_policy }}' "$workflow_file" -grep -Fq 'RECONCILIATION_OUTPUT_POLICY: ${{ steps.strategy_requirements.outputs.reconciliation_output_policy }}' "$workflow_file" -grep -Fq 'RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}' "$workflow_file" -grep -Fq 'STRATEGY_PROFILE: ${{ steps.strategy_requirements.outputs.strategy_profile }}' "$workflow_file" -grep -Fq 'if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ]; then' "$workflow_file" -grep -Fq 'if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ]; then' "$workflow_file" -grep -Fq 'if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] && [ "${CONFIG_SOURCE_POLICY:-}" = "env_only" ]; then' "$workflow_file" -grep -Fq 'if [ "${RECONCILIATION_OUTPUT_POLICY:-}" = "required" ]; then' "$workflow_file" -grep -Fq 'required_vars+=(IBKR_FEATURE_SNAPSHOT_PATH)' "$workflow_file" -grep -Fq 'required_vars+=(IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH)' "$workflow_file" -grep -Fq 'required_vars+=(IBKR_STRATEGY_CONFIG_PATH)' "$workflow_file" -grep -Fq 'required_vars+=(IBKR_RECONCILIATION_OUTPUT_PATH)' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_FEATURE_SNAPSHOT_PATH=${IBKR_FEATURE_SNAPSHOT_PATH}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=${IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_STRATEGY_CONFIG_PATH=${IBKR_STRATEGY_CONFIG_PATH}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON=${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_RECONCILIATION_OUTPUT_PATH=${IBKR_RECONCILIATION_OUTPUT_PATH}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_DRY_RUN_ONLY=${IBKR_DRY_RUN_ONLY}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_PAPER_LIQUIDATE_ONLY=${IBKR_PAPER_LIQUIDATE_ONLY}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_MIN_RESERVED_CASH_USD=${IBKR_MIN_RESERVED_CASH_USD}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_RESERVED_CASH_RATIO=${IBKR_RESERVED_CASH_RATIO}")' "$workflow_file" -grep -Fq 'env_pairs+=("IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=${IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_CHANNELS=${CRISIS_ALERT_CHANNELS}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_EMAIL_RECIPIENTS=${CRISIS_ALERT_EMAIL_RECIPIENTS}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_EMAIL_SENDER_EMAIL=${CRISIS_ALERT_EMAIL_SENDER_EMAIL}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD=${CRISIS_ALERT_EMAIL_SENDER_PASSWORD}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_HOST=${CRISIS_ALERT_EMAIL_SMTP_HOST}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_PORT=${CRISIS_ALERT_EMAIL_SMTP_PORT}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_EMAIL_SMTP_SECURITY=${CRISIS_ALERT_EMAIL_SMTP_SECURITY}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_SMS_RECIPIENTS=${CRISIS_ALERT_SMS_RECIPIENTS}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_SMS_PROVIDER=${CRISIS_ALERT_SMS_PROVIDER}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_SMS_ACCOUNT_ID=${CRISIS_ALERT_SMS_ACCOUNT_ID}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_SMS_SENDER=${CRISIS_ALERT_SMS_SENDER}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID=${CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_SMS_API_BASE_URL=${CRISIS_ALERT_SMS_API_BASE_URL}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_SMS_BODY_MAX_CHARS=${CRISIS_ALERT_SMS_BODY_MAX_CHARS}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_PUSH_RECIPIENTS=${CRISIS_ALERT_PUSH_RECIPIENTS}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_PUSH_PROVIDER=${CRISIS_ALERT_PUSH_PROVIDER}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_PUSH_API_BASE_URL=${CRISIS_ALERT_PUSH_API_BASE_URL}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_PUSH_DEVICE=${CRISIS_ALERT_PUSH_DEVICE}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_PUSH_PRIORITY=${CRISIS_ALERT_PUSH_PRIORITY}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_PUSH_TAGS=${CRISIS_ALERT_PUSH_TAGS}")' "$workflow_file" -grep -Fq 'env_pairs+=("CRISIS_ALERT_PUSH_BODY_MAX_CHARS=${CRISIS_ALERT_PUSH_BODY_MAX_CHARS}")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_FEATURE_SNAPSHOT_PATH")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_STRATEGY_CONFIG_PATH")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_RECONCILIATION_OUTPUT_PATH")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_DRY_RUN_ONLY")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_PAPER_LIQUIDATE_ONLY")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_MIN_RESERVED_CASH_USD")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_RESERVED_CASH_RATIO")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_CHANNELS")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_EMAIL_RECIPIENTS")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_EMAIL")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_HOST")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_PORT")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_EMAIL_SMTP_SECURITY")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_RECIPIENTS")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_PROVIDER")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_ACCOUNT_ID")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_SENDER")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_API_BASE_URL")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_SMS_BODY_MAX_CHARS")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_RECIPIENTS")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_PROVIDER")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_API_BASE_URL")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_DEVICE")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_PRIORITY")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_TAGS")' "$workflow_file" -grep -Fq 'remove_env_vars+=("CRISIS_ALERT_PUSH_BODY_MAX_CHARS")' "$workflow_file" + +grep -Fq 'Wait for Cloud Run deployment of current commit' "$workflow_file" +grep -Fq 'target_sha="${GITHUB_SHA}"' "$workflow_file" +grep -Fq 'Skipping Cloud Run commit wait because CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT is disabled.' "$workflow_file" +grep -Fq 'for target in plan["targets"]:' "$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 'emit_target_env_pairs()' "$workflow_file" +grep -Fq 'emit_target_remove_env_vars()' "$workflow_file" +grep -Fq 'shared_env_pairs=()' "$workflow_file" +grep -Fq 'shared_secret_pairs=()' "$workflow_file" +grep -Fq 'shared_remove_env_vars=(' "$workflow_file" +grep -Fq 'shared_remove_secret_vars=(' "$workflow_file" +grep -Fq 'shared_secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest")' "$workflow_file" +grep -Fq 'shared_secret_pairs+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD=${CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest")' "$workflow_file" +grep -Fq 'shared_env_pairs+=("CRISIS_ALERT_CHANNELS=${CRISIS_ALERT_CHANNELS}")' "$workflow_file" +grep -Fq 'shared_remove_env_vars+=("CRISIS_ALERT_CHANNELS")' "$workflow_file" +grep -Fq 'mapfile -t target_env_pairs < <(emit_target_env_pairs "${target_index}")' "$workflow_file" +grep -Fq 'mapfile -t target_remove_env_vars < <(emit_target_remove_env_vars "${target_index}")' "$workflow_file" +grep -Fq 'env_pairs=("${target_env_pairs[@]}" "${shared_env_pairs[@]}")' "$workflow_file" +grep -Fq 'remove_env_vars=("${target_remove_env_vars[@]}" "${shared_remove_env_vars[@]}")' "$workflow_file" +grep -Fq 'secret_pairs=("${shared_secret_pairs[@]}")' "$workflow_file" +grep -Fq 'remove_secret_vars=("${shared_remove_secret_vars[@]}")' "$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" +grep -Fq -- '--max-instances 1' "$workflow_file" +grep -Fq -- '--remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")' "$workflow_file" +grep -Fq -- '--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")' "$workflow_file" + grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_TO"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_GATEWAY"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_RECIPIENTS"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_SENDER_EMAIL"' "$workflow_file" grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_SMTP_HOST"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_SMTP_PORT"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_GOOGLE_VOICE_SMTP_SECURITY"' "$workflow_file" -grep -Fq '"CRISIS_ALERT_SMTP_HOST"' "$workflow_file" -grep -Fq 'secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest")' "$workflow_file" -grep -Fq 'secret_pairs+=("CRISIS_ALERT_EMAIL_SENDER_PASSWORD=${CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest")' "$workflow_file" -grep -Fq 'secret_pairs+=("CRISIS_ALERT_SMS_AUTH_TOKEN=${CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME}:latest")' "$workflow_file" -grep -Fq 'secret_pairs+=("CRISIS_ALERT_PUSH_APP_TOKEN=${CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest")' "$workflow_file" -grep -Fq 'secret_pairs+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN=${CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest")' "$workflow_file" -grep -Fq 'env_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN}")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IB_GATEWAY_ZONE")' "$workflow_file" -grep -Fq 'remove_env_vars+=("IB_GATEWAY_IP_MODE")' "$workflow_file" +grep -Fq '"CRISIS_ALERT_SMTP_PASSWORD"' "$workflow_file" 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" -grep -Fq -- '--max-instances 1' "$workflow_file" -grep -Fq -- '--remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")"' "$workflow_file" -grep -Fq -- '--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")"' "$workflow_file"