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