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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }}
CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
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 }}
TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}
FIRSTRADE_USERNAME_SECRET_NAME: ${{ vars.FIRSTRADE_USERNAME_SECRET_NAME }}
FIRSTRADE_PASSWORD_SECRET_NAME: ${{ vars.FIRSTRADE_PASSWORD_SECRET_NAME }}
Expand Down Expand Up @@ -600,6 +604,125 @@ jobs:

gcloud "${gcloud_args[@]}"

- name: Sync Cloud Scheduler schedule
if: steps.env_sync_config.outputs.enabled == 'true'
env:
RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}
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

raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip()
runtime_scheduler = {}
if raw_runtime_target:
try:
runtime_target = json.loads(raw_runtime_target)
except json.JSONDecodeError:
runtime_target = {}
scheduler = runtime_target.get("scheduler") if isinstance(runtime_target, dict) else {}
if isinstance(scheduler, dict):
runtime_scheduler = scheduler

def configured_time(key: str, name: str, default: str) -> str:
return str(runtime_scheduler.get(key) or os.environ.get(name, "").strip() or default)

print(str(runtime_scheduler.get("timezone") or "America/New_York").strip())
print(configured_time("main_time", "CLOUD_SCHEDULER_MAIN_TIME", "45 15"))
print(configured_time("probe_time", "CLOUD_SCHEDULER_PROBE_TIME", "35 9,15"))
print(configured_time("precheck_time", "CLOUD_SCHEDULER_PRECHECK_TIME", "45 9"))
PY
)
market_timezone="${scheduler_config[0]}"
main_time="${scheduler_config[1]}"
probe_time="${scheduler_config[2]}"
precheck_time="${scheduler_config[3]}"

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

for suffix in scheduler probe-scheduler precheck-scheduler; do
case "${suffix}" in
scheduler)
schedule_time="${main_time}"
scheduler_path="/run"
;;
probe-scheduler)
schedule_time="${probe_time}"
scheduler_path="/probe"
;;
precheck-scheduler)
schedule_time="${precheck_time}"
scheduler_path="/dry-run"
;;
esac

scheduler_job_candidates=("${CLOUD_RUN_SERVICE}-${suffix}")
if [[ "${CLOUD_RUN_SERVICE}" == *-service ]]; then
scheduler_job_candidates+=("${CLOUD_RUN_SERVICE%-service}-${suffix}")
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)"
Comment on lines +682 to +685

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't hide scheduler describe failures

In contexts where gcloud scheduler jobs describe fails for something other than an absent candidate job, such as missing cloudscheduler.jobs.get permission or a disabled Scheduler API, the 2>/dev/null || true here turns that failure into an empty current_schedule; the workflow then logs that the job was not found and succeeds without syncing any schedules. Since the gcloud docs define this command as showing job details, preserve the status/stderr and only continue on a confirmed NOT_FOUND.

Useful? React with 👍 / 👎.

if [ -n "${current_schedule}" ]; then
job_name="${candidate_job}"
break
fi
done

if [ -z "${current_schedule}" ]; then
echo "Cloud Scheduler job for ${CLOUD_RUN_SERVICE} ${suffix} was not found in ${scheduler_location}; skipping schedule sync."
continue
fi

desired_schedule="$(CURRENT_SCHEDULE="${current_schedule}" SCHEDULE_TIME="${schedule_time}" python - <<'PY'
import os

current_fields = os.environ["CURRENT_SCHEDULE"].split()
time_fields = os.environ["SCHEDULE_TIME"].split()
if len(current_fields) != 5:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow full cron overrides without parsing current schedule

When an existing job uses Cloud Scheduler's supported human-readable groc schedule format, current_fields is not five tokens; this check runs before the code handles a full five-field SCHEDULE_TIME, so a runtime target that supplies a complete cron expression still fails instead of replacing the old schedule. Only validate current_fields in the two-field merge branch.

Useful? React with 👍 / 👎.

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
)"

scheduler_uri="${service_url}${scheduler_path}"
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}" \
--schedule="${desired_schedule}" \
--time-zone="${market_timezone}" \
--quiet
done

- name: Clean up old Cloud Run revisions and images
if: steps.deploy_config.outputs.enabled == 'true'
run: |
Expand Down
30 changes: 30 additions & 0 deletions tests/test_sync_cloud_run_env_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ def test_sync_cloud_run_env_workflow_syncs_strategy_plugin_alert_settings():
workflow_path = Path(__file__).resolve().parents[1] / ".github/workflows/sync-cloud-run-env.yml"
workflow = workflow_path.read_text(encoding="utf-8")

for name in (
"CLOUD_SCHEDULER_LOCATION",
"CLOUD_SCHEDULER_MAIN_TIME",
"CLOUD_SCHEDULER_PROBE_TIME",
"CLOUD_SCHEDULER_PRECHECK_TIME",
):
assert f"{name}: ${{{{ vars.{name} }}}}" in workflow

for name in (
"STRATEGY_PLUGIN_ALERT_CHANNELS",
"STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS",
Expand Down Expand Up @@ -106,3 +114,25 @@ def test_sync_cloud_run_env_workflow_syncs_strategy_plugin_alert_settings():
assert '"CRISIS_ALERT_GOOGLE_VOICE_SMTP_PORT"' in workflow
assert '"CRISIS_ALERT_GOOGLE_VOICE_SMTP_SECURITY"' in workflow
assert '"CRISIS_ALERT_SMTP_HOST"' in workflow


def test_sync_cloud_run_env_workflow_syncs_scheduler_from_runtime_target():
workflow_path = Path(__file__).resolve().parents[1] / ".github/workflows/sync-cloud-run-env.yml"
workflow = workflow_path.read_text(encoding="utf-8")

assert "Sync Cloud Scheduler schedule" in workflow
assert 'scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}"' in workflow
assert 'raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip()' in workflow
assert 'scheduler = runtime_target.get("scheduler") if isinstance(runtime_target, dict) else {}' in workflow
assert 'print(str(runtime_scheduler.get("timezone") or "America/New_York").strip())' in workflow
assert 'configured_time("main_time", "CLOUD_SCHEDULER_MAIN_TIME", "45 15")' in workflow
assert 'configured_time("probe_time", "CLOUD_SCHEDULER_PROBE_TIME", "35 9,15")' in workflow
assert 'configured_time("precheck_time", "CLOUD_SCHEDULER_PRECHECK_TIME", "45 9")' in workflow
assert 'scheduler_job_candidates=("${CLOUD_RUN_SERVICE}-${suffix}")' in workflow
assert 'scheduler_job_candidates+=("${CLOUD_RUN_SERVICE%-service}-${suffix}")' in workflow
assert 'if len(time_fields) == 5:' in workflow
assert 'print(" ".join(time_fields))' in workflow
assert 'print(" ".join([*time_fields, *current_fields[2:]]))' in workflow
assert 'gcloud scheduler jobs update http "${job_name}"' in workflow
assert '--schedule="${desired_schedule}"' in workflow
assert '--time-zone="${market_timezone}"' in workflow