|
| 1 | +name: Invoke Cloud Run |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + path: |
| 7 | + description: "HTTP path to call" |
| 8 | + required: false |
| 9 | + default: "/dry-run" |
| 10 | + type: string |
| 11 | + allow_live_execution: |
| 12 | + description: "Allow calling /run live execution entrypoint" |
| 13 | + required: false |
| 14 | + default: false |
| 15 | + type: boolean |
| 16 | + |
| 17 | +env: |
| 18 | + GCP_PROJECT_ID: firstradequant |
| 19 | + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/1088907247379/locations/global/workloadIdentityPools/github-actions/providers/github-main |
| 20 | + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: firstrade-platform-deploy@firstradequant.iam.gserviceaccount.com |
| 21 | + |
| 22 | +concurrency: |
| 23 | + group: ${{ github.workflow }}-${{ github.ref_name }} |
| 24 | + cancel-in-progress: false |
| 25 | + |
| 26 | +jobs: |
| 27 | + invoke: |
| 28 | + name: Invoke Firstrade Cloud Run |
| 29 | + runs-on: ubuntu-latest |
| 30 | + timeout-minutes: 15 |
| 31 | + permissions: |
| 32 | + contents: read |
| 33 | + id-token: write |
| 34 | + env: |
| 35 | + CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} |
| 36 | + CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} |
| 37 | + CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} |
| 38 | + steps: |
| 39 | + - name: Validate inputs |
| 40 | + run: | |
| 41 | + set -euo pipefail |
| 42 | +
|
| 43 | + if [ -z "${CLOUD_RUN_REGION:-}" ] || [ -z "${CLOUD_RUN_SERVICE:-}" ]; then |
| 44 | + echo "CLOUD_RUN_REGION and CLOUD_RUN_SERVICE are required repository variables." >&2 |
| 45 | + exit 1 |
| 46 | + fi |
| 47 | +
|
| 48 | + - name: Authenticate to Google Cloud |
| 49 | + uses: google-github-actions/auth@v3 |
| 50 | + with: |
| 51 | + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} |
| 52 | + service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} |
| 53 | + |
| 54 | + - name: Set up gcloud |
| 55 | + uses: google-github-actions/setup-gcloud@v3 |
| 56 | + with: |
| 57 | + project_id: ${{ env.GCP_PROJECT_ID }} |
| 58 | + version: ">= 416.0.0" |
| 59 | + |
| 60 | + - name: Resolve service URL |
| 61 | + id: service |
| 62 | + run: | |
| 63 | + set -euo pipefail |
| 64 | +
|
| 65 | + raw_path="${{ inputs.path }}" |
| 66 | + if [ -z "${raw_path}" ]; then |
| 67 | + raw_path="/dry-run" |
| 68 | + fi |
| 69 | + if [[ "${raw_path}" != /* ]]; then |
| 70 | + raw_path="/${raw_path}" |
| 71 | + fi |
| 72 | + if [ "${raw_path}" = "/run" ] && [ "${{ inputs.allow_live_execution }}" != "true" ]; then |
| 73 | + echo "Calling ${raw_path} can trigger the live execution entrypoint. Re-run with allow_live_execution=true if this is intentional." >&2 |
| 74 | + exit 1 |
| 75 | + fi |
| 76 | +
|
| 77 | + service_json="$( |
| 78 | + gcloud run services describe "${CLOUD_RUN_SERVICE}" \ |
| 79 | + --region "${CLOUD_RUN_REGION}" \ |
| 80 | + --format=json |
| 81 | + )" |
| 82 | + service_url="$(SERVICE_JSON="${service_json}" python3 - <<'PY' |
| 83 | + import json |
| 84 | + import os |
| 85 | +
|
| 86 | + service = json.loads(os.environ["SERVICE_JSON"]) |
| 87 | + print((service.get("status") or {}).get("url") or "") |
| 88 | + PY |
| 89 | + )" |
| 90 | + if [ -z "${service_url}" ]; then |
| 91 | + echo "Unable to resolve Cloud Run service URL." >&2 |
| 92 | + exit 1 |
| 93 | + fi |
| 94 | +
|
| 95 | + service_ingress="$(SERVICE_JSON="${service_json}" python3 - <<'PY' |
| 96 | + import json |
| 97 | + import os |
| 98 | +
|
| 99 | + service = json.loads(os.environ["SERVICE_JSON"]) |
| 100 | + annotations = (service.get("metadata") or {}).get("annotations") or {} |
| 101 | + print(annotations.get("run.googleapis.com/ingress-status") or annotations.get("run.googleapis.com/ingress") or "") |
| 102 | + PY |
| 103 | + )" |
| 104 | + latest_ready_revision="$(SERVICE_JSON="${service_json}" python3 - <<'PY' |
| 105 | + import json |
| 106 | + import os |
| 107 | +
|
| 108 | + service = json.loads(os.environ["SERVICE_JSON"]) |
| 109 | + print((service.get("status") or {}).get("latestReadyRevisionName") or "") |
| 110 | + PY |
| 111 | + )" |
| 112 | + deployed_commit="$(SERVICE_JSON="${service_json}" python3 - <<'PY' |
| 113 | + import json |
| 114 | + import os |
| 115 | +
|
| 116 | + service = json.loads(os.environ["SERVICE_JSON"]) |
| 117 | + template = ((service.get("spec") or {}).get("template") or {}).get("metadata") or {} |
| 118 | + print((template.get("labels") or {}).get("commit-sha") or "") |
| 119 | + PY |
| 120 | + )" |
| 121 | +
|
| 122 | + invoke_method="direct" |
| 123 | + scheduler_job="" |
| 124 | + scheduler_location="" |
| 125 | + scheduler_expected_path="" |
| 126 | + if [ "${service_ingress}" = "internal" ]; then |
| 127 | + scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" |
| 128 | + case "${raw_path}" in |
| 129 | + /run) |
| 130 | + scheduler_job="${CLOUD_RUN_SERVICE}-scheduler" |
| 131 | + scheduler_expected_path="/run" |
| 132 | + ;; |
| 133 | + /probe) |
| 134 | + scheduler_job="${CLOUD_RUN_SERVICE}-probe-scheduler" |
| 135 | + scheduler_expected_path="/probe" |
| 136 | + ;; |
| 137 | + /dry-run) |
| 138 | + scheduler_job="${CLOUD_RUN_SERVICE}-precheck-scheduler" |
| 139 | + scheduler_expected_path="/dry-run" |
| 140 | + ;; |
| 141 | + *) |
| 142 | + echo "Cloud Run service ${CLOUD_RUN_SERVICE} has internal ingress, so GitHub-hosted runners cannot curl ${raw_path} directly." >&2 |
| 143 | + echo "Use one of the scheduler-backed paths: /run, /probe, /dry-run." >&2 |
| 144 | + exit 1 |
| 145 | + ;; |
| 146 | + esac |
| 147 | +
|
| 148 | + scheduler_uri="$( |
| 149 | + gcloud scheduler jobs describe "${scheduler_job}" \ |
| 150 | + --location="${scheduler_location}" \ |
| 151 | + --format='value(httpTarget.uri)' 2>/dev/null || true |
| 152 | + )" |
| 153 | + if [ -z "${scheduler_uri}" ]; then |
| 154 | + echo "Cloud Scheduler job ${scheduler_job} was not found in ${scheduler_location}." >&2 |
| 155 | + exit 1 |
| 156 | + fi |
| 157 | + scheduler_path="$(SCHEDULER_URI="${scheduler_uri}" python3 - <<'PY' |
| 158 | + import os |
| 159 | + from urllib.parse import urlparse |
| 160 | +
|
| 161 | + def normalize(path: str) -> str: |
| 162 | + clean = (path or "/").rstrip("/") |
| 163 | + return clean or "/" |
| 164 | +
|
| 165 | + print(normalize(urlparse(os.environ["SCHEDULER_URI"]).path)) |
| 166 | + PY |
| 167 | + )" |
| 168 | + requested_path="$(RAW_PATH="${scheduler_expected_path}" python3 - <<'PY' |
| 169 | + import os |
| 170 | +
|
| 171 | + clean = (os.environ["RAW_PATH"] or "/").rstrip("/") |
| 172 | + print(clean or "/") |
| 173 | + PY |
| 174 | + )" |
| 175 | + if [ "${scheduler_path}" != "${requested_path}" ]; then |
| 176 | + echo "Cloud Scheduler job ${scheduler_job} targets ${scheduler_uri}, not ${scheduler_expected_path}." >&2 |
| 177 | + exit 1 |
| 178 | + fi |
| 179 | + invoke_method="scheduler" |
| 180 | + fi |
| 181 | +
|
| 182 | + echo "Cloud Run service: ${CLOUD_RUN_SERVICE}" |
| 183 | + echo "Cloud Run region: ${CLOUD_RUN_REGION}" |
| 184 | + echo "Cloud Run URL: ${service_url}" |
| 185 | + echo "Cloud Run ingress: ${service_ingress:-<empty>}" |
| 186 | + echo "Latest ready revision: ${latest_ready_revision:-<empty>}" |
| 187 | + echo "Deployed commit: ${deployed_commit:-<empty>}" |
| 188 | + echo "Invoke method: ${invoke_method}" |
| 189 | + if [ -n "${scheduler_job}" ]; then |
| 190 | + echo "Cloud Scheduler job: ${scheduler_job}" |
| 191 | + echo "Cloud Scheduler location: ${scheduler_location}" |
| 192 | + fi |
| 193 | +
|
| 194 | + { |
| 195 | + echo "url=${service_url}" |
| 196 | + echo "path=${raw_path}" |
| 197 | + echo "invoke_method=${invoke_method}" |
| 198 | + echo "scheduler_job=${scheduler_job}" |
| 199 | + echo "scheduler_location=${scheduler_location}" |
| 200 | + } >> "$GITHUB_OUTPUT" |
| 201 | +
|
| 202 | + - name: Authenticate for service invocation |
| 203 | + if: steps.service.outputs.invoke_method == 'direct' |
| 204 | + id: invoke-auth |
| 205 | + uses: google-github-actions/auth@v3 |
| 206 | + with: |
| 207 | + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} |
| 208 | + service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} |
| 209 | + token_format: id_token |
| 210 | + id_token_audience: ${{ steps.service.outputs.url }} |
| 211 | + id_token_include_email: true |
| 212 | + |
| 213 | + - name: Invoke service |
| 214 | + if: steps.service.outputs.invoke_method == 'direct' |
| 215 | + run: | |
| 216 | + set -euo pipefail |
| 217 | +
|
| 218 | + curl --fail-with-body --show-error --silent \ |
| 219 | + --request POST \ |
| 220 | + --header "Authorization: Bearer ${{ steps.invoke-auth.outputs.id_token }}" \ |
| 221 | + "${{ steps.service.outputs.url }}${{ steps.service.outputs.path }}" |
| 222 | +
|
| 223 | + - name: Invoke internal service through Cloud Scheduler |
| 224 | + if: steps.service.outputs.invoke_method == 'scheduler' |
| 225 | + run: | |
| 226 | + set -euo pipefail |
| 227 | +
|
| 228 | + started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 229 | + scheduler_job="${{ steps.service.outputs.scheduler_job }}" |
| 230 | + scheduler_location="${{ steps.service.outputs.scheduler_location }}" |
| 231 | +
|
| 232 | + echo "Triggering ${scheduler_job} at ${started_at}." |
| 233 | + gcloud scheduler jobs run "${scheduler_job}" \ |
| 234 | + --location="${scheduler_location}" \ |
| 235 | + --quiet |
| 236 | +
|
| 237 | + deadline=$((SECONDS + 180)) |
| 238 | + while true; do |
| 239 | + job_json="$( |
| 240 | + gcloud scheduler jobs describe "${scheduler_job}" \ |
| 241 | + --location="${scheduler_location}" \ |
| 242 | + --format=json |
| 243 | + )" |
| 244 | + attempt_seen="$(JOB_JSON="${job_json}" STARTED_AT="${started_at}" python3 - <<'PY' |
| 245 | + import datetime as dt |
| 246 | + import json |
| 247 | + import os |
| 248 | +
|
| 249 | + def parse_timestamp(value: str) -> dt.datetime | None: |
| 250 | + if not value: |
| 251 | + return None |
| 252 | + text = value.replace("Z", "+00:00") |
| 253 | + return dt.datetime.fromisoformat(text) |
| 254 | +
|
| 255 | + job = json.loads(os.environ["JOB_JSON"]) |
| 256 | + last_attempt = parse_timestamp(job.get("lastAttemptTime") or "") |
| 257 | + started_at = parse_timestamp(os.environ["STARTED_AT"]) |
| 258 | + print("true" if last_attempt and started_at and last_attempt >= started_at else "false") |
| 259 | + PY |
| 260 | + )" |
| 261 | + status_code="$(JOB_JSON="${job_json}" python3 - <<'PY' |
| 262 | + import json |
| 263 | + import os |
| 264 | +
|
| 265 | + status = (json.loads(os.environ["JOB_JSON"]).get("status") or {}) |
| 266 | + print(status.get("code") or "") |
| 267 | + PY |
| 268 | + )" |
| 269 | + status_message="$(JOB_JSON="${job_json}" python3 - <<'PY' |
| 270 | + import json |
| 271 | + import os |
| 272 | +
|
| 273 | + status = (json.loads(os.environ["JOB_JSON"]).get("status") or {}) |
| 274 | + print(status.get("message") or "") |
| 275 | + PY |
| 276 | + )" |
| 277 | + last_attempt_time="$(JOB_JSON="${job_json}" python3 - <<'PY' |
| 278 | + import json |
| 279 | + import os |
| 280 | +
|
| 281 | + print(json.loads(os.environ["JOB_JSON"]).get("lastAttemptTime") or "") |
| 282 | + PY |
| 283 | + )" |
| 284 | +
|
| 285 | + if [ "${attempt_seen}" = "true" ]; then |
| 286 | + if [ -n "${status_code}" ] && [ "${status_code}" != "0" ]; then |
| 287 | + echo "Cloud Scheduler job ${scheduler_job} failed with status ${status_code}: ${status_message}" >&2 |
| 288 | + exit 1 |
| 289 | + fi |
| 290 | + echo "Cloud Scheduler job ${scheduler_job} ran at ${last_attempt_time}." |
| 291 | + break |
| 292 | + fi |
| 293 | +
|
| 294 | + if [ "${SECONDS}" -ge "${deadline}" ]; then |
| 295 | + echo "Timed out waiting for Cloud Scheduler job ${scheduler_job} to record a new attempt." >&2 |
| 296 | + exit 1 |
| 297 | + fi |
| 298 | +
|
| 299 | + echo "Waiting for Cloud Scheduler job ${scheduler_job} attempt..." |
| 300 | + sleep 10 |
| 301 | + done |
0 commit comments