Skip to content

Commit 2772250

Browse files
authored
Merge pull request #129 from QuantStrategyLab/chore/add-invoke-cloud-run-workflow
Add manual Cloud Run invoke workflow
2 parents ebf7f7f + 16c8db7 commit 2772250

1 file changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
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

Comments
 (0)