@meffecta/agent 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +197 -0
- package/IMPLEMENTATION.md +292 -0
- package/README.md +64 -0
- package/bin/meffecta-agent.js +120 -0
- package/deployment.env.example +54 -0
- package/engine.json +4 -0
- package/lib/commands.js +407 -0
- package/lib/config.js +56 -0
- package/lib/gcloud.js +109 -0
- package/package.json +38 -0
- package/scripts/artifact-cleanup-policy.json +17 -0
- package/scripts/create-project.sh +106 -0
- package/scripts/deploy.sh +169 -0
- package/scripts/lib/deployment.sh +109 -0
- package/scripts/link-billing.sh +143 -0
- package/scripts/mint-gmail-token.mjs +126 -0
- package/scripts/mint-graph-token.mjs +152 -0
- package/scripts/set-artifact-cleanup.sh +108 -0
- package/scripts/set-env.sh +92 -0
- package/scripts/set-secret.sh +86 -0
- package/scripts/setup-infrastructure.sh +263 -0
- package/scripts/setup-scheduler.sh +287 -0
- package/scripts/update-tooling.sh +117 -0
- package/scripts/verify-credentials.mjs +557 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# One-time GCP provisioning for a deployment of the agent. Idempotent — safe to re-run.
|
|
5
|
+
# See IMPLEMENTATION.md for the whole set-up, of which this is step 6.
|
|
6
|
+
#
|
|
7
|
+
# Give the agent a GCP project of its OWN, separate from any product project it reads:
|
|
8
|
+
# this project's service account holds project-wide secret access, and the mail refresh
|
|
9
|
+
# tokens here must not be readable by another system's machinery.
|
|
10
|
+
#
|
|
11
|
+
# Prerequisite (once, manual): create the project and link billing —
|
|
12
|
+
# gcloud projects create <PROJECT> --organization=<ORG_ID> # or --folder
|
|
13
|
+
# gcloud billing projects link <PROJECT> --billing-account=<BILLING_ACCOUNT_ID>
|
|
14
|
+
#
|
|
15
|
+
# Which deployment it provisions comes from ./deployment.env (or --config <file>) —
|
|
16
|
+
# see deployment.env.example. Nothing is defaulted to another deployment's project.
|
|
17
|
+
|
|
18
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
19
|
+
|
|
20
|
+
while [ $# -gt 0 ]; do
|
|
21
|
+
case "$1" in
|
|
22
|
+
--config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
|
|
23
|
+
-h|--help) sed -n '4,25p' "$0"; exit 0 ;;
|
|
24
|
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
|
|
28
|
+
require_gcloud
|
|
29
|
+
resolve_deployment
|
|
30
|
+
announce_target "Provisioning infrastructure"
|
|
31
|
+
|
|
32
|
+
ACCOUNT="${ACCOUNT:-}"
|
|
33
|
+
MEMORY_BUCKET="${PROJECT}-memory"
|
|
34
|
+
AUDIT_BUCKET="${PROJECT}-audit"
|
|
35
|
+
RUNTIME_SA_NAME="agent-runtime"
|
|
36
|
+
RUNTIME_SA="${RUNTIME_SA_NAME}@${PROJECT}.iam.gserviceaccount.com"
|
|
37
|
+
DEPLOY_SA_NAME="github-deployer"
|
|
38
|
+
DEPLOY_SA="${DEPLOY_SA_NAME}@${PROJECT}.iam.gserviceaccount.com"
|
|
39
|
+
# Deploys are not wired to the engine repo's CI: it publishes images to GHCR, and a
|
|
40
|
+
# deployment rolls one out when it chooses (scripts/deploy.sh). The deployer SA +
|
|
41
|
+
# Workload Identity Federation below are therefore optional — provision them only if
|
|
42
|
+
# THIS deployment wants a CI deploy job of its own, from its content repo:
|
|
43
|
+
# SETUP_CI_DEPLOY=true GITHUB_REPO=meffecta/agent-home ./scripts/setup-infrastructure.sh
|
|
44
|
+
SETUP_CI_DEPLOY="${SETUP_CI_DEPLOY:-false}"
|
|
45
|
+
GITHUB_REPO="${GITHUB_REPO:-meffecta/agent-home}" # owner/repo a CI deploy would run from
|
|
46
|
+
|
|
47
|
+
# ACCOUNT is optional: unset means whichever account gcloud is currently logged in as.
|
|
48
|
+
GC="gcloud --project=${PROJECT}"
|
|
49
|
+
[ -n "${ACCOUNT}" ] && GC="${GC} --account=${ACCOUNT}"
|
|
50
|
+
|
|
51
|
+
echo "🔧 Enabling APIs..."
|
|
52
|
+
${GC} services enable \
|
|
53
|
+
run.googleapis.com \
|
|
54
|
+
cloudbuild.googleapis.com \
|
|
55
|
+
artifactregistry.googleapis.com \
|
|
56
|
+
secretmanager.googleapis.com \
|
|
57
|
+
iamcredentials.googleapis.com \
|
|
58
|
+
cloudscheduler.googleapis.com \
|
|
59
|
+
cloudtasks.googleapis.com \
|
|
60
|
+
storage.googleapis.com \
|
|
61
|
+
gmail.googleapis.com \
|
|
62
|
+
calendar-json.googleapis.com \
|
|
63
|
+
drive.googleapis.com \
|
|
64
|
+
sheets.googleapis.com \
|
|
65
|
+
docs.googleapis.com \
|
|
66
|
+
slides.googleapis.com \
|
|
67
|
+
googleads.googleapis.com \
|
|
68
|
+
searchconsole.googleapis.com \
|
|
69
|
+
analyticsdata.googleapis.com \
|
|
70
|
+
iam.googleapis.com
|
|
71
|
+
|
|
72
|
+
echo "🐳 Artifact Registry repo..."
|
|
73
|
+
if ! ${GC} artifacts repositories describe "${ARTIFACT_REPO}" --location="${REGION}" &>/dev/null; then
|
|
74
|
+
${GC} artifacts repositories create "${ARTIFACT_REPO}" --location="${REGION}" --repository-format=docker
|
|
75
|
+
fi
|
|
76
|
+
# The images here are a cache of ghcr.io/meffecta/agent, not an archive, so they are worth
|
|
77
|
+
# expiring — see set-artifact-cleanup.sh for why that is safe and what the policy keeps.
|
|
78
|
+
"$(dirname "${BASH_SOURCE[0]}")/set-artifact-cleanup.sh" ${CONFIG_FILE:+--config "${CONFIG_FILE}"} >/dev/null
|
|
79
|
+
|
|
80
|
+
# --- Runtime service account (everything the agent itself can do) ---
|
|
81
|
+
if ! ${GC} iam service-accounts describe "${RUNTIME_SA}" &>/dev/null; then
|
|
82
|
+
echo "🆕 Creating runtime service account: ${RUNTIME_SA}"
|
|
83
|
+
${GC} iam service-accounts create "${RUNTIME_SA_NAME}" --display-name="Meffecta Agent"
|
|
84
|
+
for _ in $(seq 1 12); do
|
|
85
|
+
${GC} iam service-accounts describe "${RUNTIME_SA}" &>/dev/null && break
|
|
86
|
+
sleep 5
|
|
87
|
+
done
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# --- Buckets: memory (mounted, agent-writable) + audit (write-once, never mounted) ---
|
|
91
|
+
if ! ${GC} storage buckets describe "gs://${MEMORY_BUCKET}" &>/dev/null; then
|
|
92
|
+
echo "🆕 Creating memory bucket: ${MEMORY_BUCKET}"
|
|
93
|
+
${GC} storage buckets create "gs://${MEMORY_BUCKET}" --location="${REGION}" --uniform-bucket-level-access
|
|
94
|
+
fi
|
|
95
|
+
if ! ${GC} storage buckets describe "gs://${AUDIT_BUCKET}" &>/dev/null; then
|
|
96
|
+
echo "🆕 Creating audit bucket: ${AUDIT_BUCKET} (365d retention)"
|
|
97
|
+
${GC} storage buckets create "gs://${AUDIT_BUCKET}" --location="${REGION}" --uniform-bucket-level-access \
|
|
98
|
+
--retention-period=365d
|
|
99
|
+
fi
|
|
100
|
+
# Cloud Build source staging — only for a deployment that builds images in GCP rather
|
|
101
|
+
# than pulling the published one. Source tarballs are transient, so a 30-day lifecycle
|
|
102
|
+
# keeps it from growing.
|
|
103
|
+
CLOUDBUILD_BUCKET="${PROJECT}-cloudbuild"
|
|
104
|
+
if [ "${SETUP_CI_DEPLOY}" = "true" ] && ! ${GC} storage buckets describe "gs://${CLOUDBUILD_BUCKET}" &>/dev/null; then
|
|
105
|
+
echo "🆕 Creating Cloud Build staging bucket: ${CLOUDBUILD_BUCKET}"
|
|
106
|
+
${GC} storage buckets create "gs://${CLOUDBUILD_BUCKET}" --location="${REGION}" --uniform-bucket-level-access
|
|
107
|
+
LIFECYCLE_FILE=$(mktemp)
|
|
108
|
+
printf '{"rule":[{"action":{"type":"Delete"},"condition":{"age":30}}]}' >"${LIFECYCLE_FILE}"
|
|
109
|
+
${GC} storage buckets update "gs://${CLOUDBUILD_BUCKET}" --lifecycle-file="${LIFECYCLE_FILE}"
|
|
110
|
+
rm -f "${LIFECYCLE_FILE}"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
echo "🔐 Runtime IAM..."
|
|
114
|
+
${GC} storage buckets add-iam-policy-binding "gs://${MEMORY_BUCKET}" \
|
|
115
|
+
--member="serviceAccount:${RUNTIME_SA}" --role="roles/storage.objectAdmin" --quiet >/dev/null
|
|
116
|
+
${GC} storage buckets add-iam-policy-binding "gs://${AUDIT_BUCKET}" \
|
|
117
|
+
--member="serviceAccount:${RUNTIME_SA}" --role="roles/storage.objectCreator" --quiet >/dev/null
|
|
118
|
+
${GC} projects add-iam-policy-binding "${PROJECT}" \
|
|
119
|
+
--member="serviceAccount:${RUNTIME_SA}" --role="roles/secretmanager.secretAccessor" --quiet >/dev/null
|
|
120
|
+
|
|
121
|
+
PROJECT_NUMBER=$(${GC} projects describe "${PROJECT}" --format='value(projectNumber)')
|
|
122
|
+
|
|
123
|
+
# --- Deployer service account + Workload Identity Federation (optional; see the flag) ---
|
|
124
|
+
if [ "${SETUP_CI_DEPLOY}" = "true" ]; then
|
|
125
|
+
if ! ${GC} iam service-accounts describe "${DEPLOY_SA}" &>/dev/null; then
|
|
126
|
+
echo "🆕 Creating deployer service account: ${DEPLOY_SA}"
|
|
127
|
+
${GC} iam service-accounts create "${DEPLOY_SA_NAME}" --display-name="GitHub Actions deployer"
|
|
128
|
+
for _ in $(seq 1 12); do
|
|
129
|
+
${GC} iam service-accounts describe "${DEPLOY_SA}" &>/dev/null && break
|
|
130
|
+
sleep 5
|
|
131
|
+
done
|
|
132
|
+
fi
|
|
133
|
+
for role in roles/run.admin roles/cloudbuild.builds.editor roles/artifactregistry.writer \
|
|
134
|
+
roles/storage.admin roles/serviceusage.serviceUsageConsumer; do
|
|
135
|
+
${GC} projects add-iam-policy-binding "${PROJECT}" \
|
|
136
|
+
--member="serviceAccount:${DEPLOY_SA}" --role="${role}" --quiet >/dev/null
|
|
137
|
+
done
|
|
138
|
+
# Cloud Build runs as its default SA; the deployer must be allowed to hand work to it,
|
|
139
|
+
# and both must be able to deploy a service that runs as the runtime SA.
|
|
140
|
+
${GC} iam service-accounts add-iam-policy-binding "${RUNTIME_SA}" \
|
|
141
|
+
--member="serviceAccount:${DEPLOY_SA}" --role="roles/iam.serviceAccountUser" --quiet >/dev/null
|
|
142
|
+
CLOUDBUILD_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
|
|
143
|
+
${GC} iam service-accounts add-iam-policy-binding "${RUNTIME_SA}" \
|
|
144
|
+
--member="serviceAccount:${CLOUDBUILD_SA}" --role="roles/iam.serviceAccountUser" --quiet >/dev/null
|
|
145
|
+
# `gcloud builds submit` runs the build as the default compute SA: the deployer must be
|
|
146
|
+
# allowed to act as it, and it needs to read staged source, push images, and deploy.
|
|
147
|
+
${GC} iam service-accounts add-iam-policy-binding "${CLOUDBUILD_SA}" \
|
|
148
|
+
--member="serviceAccount:${DEPLOY_SA}" --role="roles/iam.serviceAccountUser" --quiet >/dev/null
|
|
149
|
+
for role in roles/cloudbuild.builds.builder roles/artifactregistry.writer roles/run.admin; do
|
|
150
|
+
${GC} projects add-iam-policy-binding "${PROJECT}" \
|
|
151
|
+
--member="serviceAccount:${CLOUDBUILD_SA}" --role="${role}" --quiet >/dev/null
|
|
152
|
+
done
|
|
153
|
+
|
|
154
|
+
POOL_ID="github-pool"
|
|
155
|
+
PROVIDER_ID="github-provider"
|
|
156
|
+
if ! ${GC} iam workload-identity-pools describe "${POOL_ID}" --location=global &>/dev/null; then
|
|
157
|
+
echo "🆕 Creating Workload Identity pool + provider for GitHub Actions..."
|
|
158
|
+
${GC} iam workload-identity-pools create "${POOL_ID}" --location=global --display-name="GitHub Actions"
|
|
159
|
+
fi
|
|
160
|
+
if ! ${GC} iam workload-identity-pools providers describe "${PROVIDER_ID}" \
|
|
161
|
+
--location=global --workload-identity-pool="${POOL_ID}" &>/dev/null; then
|
|
162
|
+
${GC} iam workload-identity-pools providers create-oidc "${PROVIDER_ID}" \
|
|
163
|
+
--location=global --workload-identity-pool="${POOL_ID}" \
|
|
164
|
+
--display-name="GitHub OIDC" \
|
|
165
|
+
--issuer-uri="https://token.actions.githubusercontent.com" \
|
|
166
|
+
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
|
|
167
|
+
--attribute-condition="assertion.repository == '${GITHUB_REPO}'"
|
|
168
|
+
fi
|
|
169
|
+
${GC} iam service-accounts add-iam-policy-binding "${DEPLOY_SA}" \
|
|
170
|
+
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/attribute.repository/${GITHUB_REPO}" \
|
|
171
|
+
--role="roles/iam.workloadIdentityUser" --quiet >/dev/null
|
|
172
|
+
else
|
|
173
|
+
echo "⏭️ Skipping deployer SA + Workload Identity Federation (SETUP_CI_DEPLOY is not true)."
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# --- Cloud Run service shell (placeholder image; scripts/deploy.sh rolls out a real one) ---
|
|
177
|
+
if ! ${GC} run services describe "${SERVICE}" --region="${REGION}" &>/dev/null; then
|
|
178
|
+
echo "🆕 Creating Cloud Run service: ${SERVICE}"
|
|
179
|
+
${GC} run services deploy "${SERVICE}" --region="${REGION}" \
|
|
180
|
+
--image="gcr.io/cloudrun/hello" --allow-unauthenticated \
|
|
181
|
+
--service-account="${RUNTIME_SA}" 2>/dev/null ||
|
|
182
|
+
${GC} run deploy "${SERVICE}" --region="${REGION}" \
|
|
183
|
+
--image="gcr.io/cloudrun/hello" --allow-unauthenticated \
|
|
184
|
+
--service-account="${RUNTIME_SA}"
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
echo "⚙️ Applying service settings (instances, memory volume, env)..."
|
|
188
|
+
VOLUME_FLAGS=()
|
|
189
|
+
if ! ${GC} run services describe "${SERVICE}" --region="${REGION}" --format=yaml 2>/dev/null | grep -q "name: memory"; then
|
|
190
|
+
VOLUME_FLAGS+=(--add-volume="name=memory,type=cloud-storage,bucket=${MEMORY_BUCKET}" --add-volume-mount="volume=memory,mount-path=/memory")
|
|
191
|
+
fi
|
|
192
|
+
# The runtime shape, from deployment.env (see lib/deployment.sh for the defaults) — the
|
|
193
|
+
# same values deploy.sh re-asserts on every rollout, so the two never disagree.
|
|
194
|
+
#
|
|
195
|
+
# Scale to zero means CPU billed only while a request is open. That is affordable because
|
|
196
|
+
# the service is idle almost all day, and it is only correct because nothing here fires on
|
|
197
|
+
# its own: scripts/setup-scheduler.sh gives the deployment its external triggers, and every
|
|
198
|
+
# run happens inside a request the handler holds open (src/keyed-runs.ts). REQUEST_TIMEOUT
|
|
199
|
+
# is the ceiling on one such held request; --cpu-boost pays for the cold start each wake-up
|
|
200
|
+
# costs, which for this service includes cloning the content repo. MAX_INSTANCES=1 keeps
|
|
201
|
+
# every held request on one instance, which is what makes the engine's serial queue mean
|
|
202
|
+
# anything.
|
|
203
|
+
# The guarded array expansion keeps macOS bash 3.2 (set -u) happy when no flags are needed.
|
|
204
|
+
case "${SCALING}" in
|
|
205
|
+
scale-to-zero) SCALE_FLAGS=(--min-instances=0 --cpu-throttling --cpu-boost) ;;
|
|
206
|
+
always-on) SCALE_FLAGS=(--min-instances=1 --no-cpu-throttling) ;;
|
|
207
|
+
*) echo "Unknown SCALING=\"${SCALING}\" — use scale-to-zero or always-on." >&2; exit 2 ;;
|
|
208
|
+
esac
|
|
209
|
+
${GC} run services update "${SERVICE}" --region="${REGION}" \
|
|
210
|
+
--service-account="${RUNTIME_SA}" \
|
|
211
|
+
--max-instances="${MAX_INSTANCES}" \
|
|
212
|
+
"${SCALE_FLAGS[@]}" \
|
|
213
|
+
--memory="${MEMORY}" --cpu="${CPU}" --timeout="${REQUEST_TIMEOUT}" \
|
|
214
|
+
--execution-environment=gen2 \
|
|
215
|
+
${VOLUME_FLAGS[@]+"${VOLUME_FLAGS[@]}"} \
|
|
216
|
+
--update-env-vars="TIMEZONE=${TIMEZONE},AUDIT_BUCKET=${AUDIT_BUCKET}"
|
|
217
|
+
|
|
218
|
+
echo ""
|
|
219
|
+
echo "============================================================"
|
|
220
|
+
echo "✅ Infrastructure ready. Manual steps:"
|
|
221
|
+
echo ""
|
|
222
|
+
echo " 1. Point this deployment at its content repo — jobs, worlds, SYSTEM.md:"
|
|
223
|
+
echo " gcloud run services update ${SERVICE} --region ${REGION} --project ${PROJECT} \\"
|
|
224
|
+
echo " --update-env-vars GIT_REPO_URL=https://github.com/<owner>/<content-repo>.git"
|
|
225
|
+
echo " GITHUB_TOKEN (below) must be able to clone it, and every repo its jobs name."
|
|
226
|
+
echo ""
|
|
227
|
+
echo " 2. Gmail OAuth (once): in this project's console — OAuth consent screen"
|
|
228
|
+
echo " (External, PUBLISH to production) + a Desktop-app OAuth client. Then per"
|
|
229
|
+
echo " account: node scripts/mint-gmail-token.mjs --client-id ... --client-secret ..."
|
|
230
|
+
echo " [--account <NAME>] [--readonly] (sign in as that account)."
|
|
231
|
+
echo ""
|
|
232
|
+
echo " 3. Create + wire the secrets:"
|
|
233
|
+
echo " printf '%s' '<value>' | gcloud secrets create <NAME> --data-file=- --project ${PROJECT}"
|
|
234
|
+
echo " gcloud run services update ${SERVICE} --region ${REGION} --project ${PROJECT} \\"
|
|
235
|
+
echo " --update-secrets '<NAME>=<NAME>:latest'"
|
|
236
|
+
echo " Required: AGENT_WEBHOOK_SECRET, CLAUDE_CODE_OAUTH_TOKEN (claude setup-token), GITHUB_TOKEN"
|
|
237
|
+
echo " Gmail: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN — and one"
|
|
238
|
+
echo " GMAIL_<NAME>_REFRESH_TOKEN per extra account (e.g. GMAIL_MEVISIO_REFRESH_TOKEN)"
|
|
239
|
+
echo " Optional: GRAFANA_LOGS_HOST/USERNAME + GRAFANA_API_KEY"
|
|
240
|
+
echo " Plain vars (--update-env-vars, not secrets): GMAIL_ADDRESS, GMAIL_<NAME>_ADDRESS"
|
|
241
|
+
echo ""
|
|
242
|
+
echo " 4. Roll out an engine image (the engine repo's CI publishes them to GHCR):"
|
|
243
|
+
echo " PROJECT=${PROJECT} REGION=${REGION} SERVICE=${SERVICE} ./scripts/deploy.sh"
|
|
244
|
+
echo " (defaults to the newest published image; --tag sha-<short> pins one)"
|
|
245
|
+
echo " Content changes need no deploy at all — they are live on the next run."
|
|
246
|
+
echo ""
|
|
247
|
+
echo " 5. Give it its triggers — the service scales to zero and fires nothing by itself:"
|
|
248
|
+
echo " ./scripts/setup-scheduler.sh"
|
|
249
|
+
echo " Creates the Cloud Tasks queue and one Cloud Scheduler job per cron: in the"
|
|
250
|
+
echo " content repo, reading the job list from the running service. Needs step 4 done"
|
|
251
|
+
echo " first, and re-run it whenever those registrations change (deploy.sh then does)."
|
|
252
|
+
if [ "${SETUP_CI_DEPLOY}" = "true" ]; then
|
|
253
|
+
WIF_PROVIDER="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}"
|
|
254
|
+
echo ""
|
|
255
|
+
echo " 6. For a CI deploy from ${GITHUB_REPO} → Settings → Secrets → Actions:"
|
|
256
|
+
echo " GCP_WORKLOAD_IDENTITY_PROVIDER = ${WIF_PROVIDER}"
|
|
257
|
+
echo " GCP_DEPLOY_SERVICE_ACCOUNT = ${DEPLOY_SA}"
|
|
258
|
+
fi
|
|
259
|
+
echo ""
|
|
260
|
+
echo " Inspect everything the agent can do:"
|
|
261
|
+
echo " gcloud projects get-iam-policy ${PROJECT} --flatten='bindings[].members' \\"
|
|
262
|
+
echo " --filter='bindings.members:${RUNTIME_SA}'"
|
|
263
|
+
echo "============================================================"
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Give one deployment its external triggers, so its Cloud Run service can scale to zero and
|
|
5
|
+
# bill CPU only while a request is open.
|
|
6
|
+
#
|
|
7
|
+
# An agent that drives itself needs an instance that is always awake — a timer on a
|
|
8
|
+
# throttled CPU either misfires or never fires at all, and a job started in the background
|
|
9
|
+
# of a response dies with it. So under `--cpu-throttling` every piece of work has to arrive
|
|
10
|
+
# as a request instead. This script creates the things that send them:
|
|
11
|
+
#
|
|
12
|
+
# 1. A Cloud Tasks queue. It carries the triggers that must be answered immediately —
|
|
13
|
+
# webhooks, manual runs, a follow-up due in three days — back to the service as
|
|
14
|
+
# requests it can hold. The runtime service account gets enqueuer on it, and the
|
|
15
|
+
# service gets AGENT_TASKS_QUEUE + AGENT_PUBLIC_URL, which together are what put the
|
|
16
|
+
# engine in this mode.
|
|
17
|
+
# 2. One Cloud Scheduler job per `cron:` in the content repo, POSTing
|
|
18
|
+
# /jobs/<name>/run?source=scheduler. Stale ones are deleted.
|
|
19
|
+
# 3. A housekeeping sweep, POSTing /internal/sweep: due follow-ups, runs a dead process
|
|
20
|
+
# left behind, and the watched inboxes. Its cadence is the inbox poll interval when
|
|
21
|
+
# any job watches an inbox, hourly otherwise.
|
|
22
|
+
#
|
|
23
|
+
# Both retry with short backoff up to 4 times, and each attempt may take up to 1780s: the
|
|
24
|
+
# handler answers 503 when a run outlives its attempt and the retry re-attaches to that same
|
|
25
|
+
# run (src/keyed-runs.ts), so the chain of attempts is what keeps CPU on a long job. Four
|
|
26
|
+
# attempts of ~28 minutes cover the longest run with room for a genuine failure.
|
|
27
|
+
#
|
|
28
|
+
# The job list comes from the running service (GET /jobs), which is the only thing that
|
|
29
|
+
# knows it: the engine ships no jobs, and registrations are read from a boot-time clone of
|
|
30
|
+
# the content repo. So the service must be deployed and healthy before this runs — and it
|
|
31
|
+
# has to run again whenever those registrations change, which is exactly when the service
|
|
32
|
+
# is restarted for them anyway. scripts/deploy.sh does it automatically.
|
|
33
|
+
#
|
|
34
|
+
# Usage:
|
|
35
|
+
# scripts/setup-scheduler.sh # from the content repo (./deployment.env)
|
|
36
|
+
# scripts/setup-scheduler.sh --dry-run # print what it would create, change nothing
|
|
37
|
+
#
|
|
38
|
+
# Which deployment: ./deployment.env, or --config <file>. See deployment.env.example.
|
|
39
|
+
|
|
40
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
41
|
+
|
|
42
|
+
DRY_RUN=false
|
|
43
|
+
QUEUE_NAME=""
|
|
44
|
+
|
|
45
|
+
while [ $# -gt 0 ]; do
|
|
46
|
+
case "$1" in
|
|
47
|
+
--config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
|
|
48
|
+
--queue) QUEUE_NAME="${2:?--queue needs a value}"; shift 2 ;;
|
|
49
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
50
|
+
-h|--help) sed -n '3,38p' "$0"; exit 0 ;;
|
|
51
|
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
52
|
+
esac
|
|
53
|
+
done
|
|
54
|
+
|
|
55
|
+
require_gcloud
|
|
56
|
+
command -v python3 >/dev/null || { echo "python3 is required." >&2; exit 1; }
|
|
57
|
+
resolve_deployment
|
|
58
|
+
announce_target "Wiring external triggers for ${SERVICE}"
|
|
59
|
+
|
|
60
|
+
QUEUE="${QUEUE_NAME:-${SERVICE}-runs}"
|
|
61
|
+
QUEUE_PATH="projects/${PROJECT}/locations/${REGION}/queues/${QUEUE}"
|
|
62
|
+
JOB_PREFIX="${SERVICE}-job-"
|
|
63
|
+
SWEEP_JOB="${SERVICE}-sweep"
|
|
64
|
+
ATTEMPT_DEADLINE="1780s"
|
|
65
|
+
|
|
66
|
+
GC="gcloud --project=${PROJECT}"
|
|
67
|
+
[ -n "${ACCOUNT:-}" ] && GC="${GC} --account=${ACCOUNT}"
|
|
68
|
+
|
|
69
|
+
# The triggers authenticate with the webhook secret, so it necessarily appears in the
|
|
70
|
+
# arguments of the calls that create them — and gcloud echoes the arguments back when it
|
|
71
|
+
# rejects one. Everything this script prints goes through here first.
|
|
72
|
+
redact() {
|
|
73
|
+
if [ -n "${WEBHOOK_SECRET:-}" ]; then
|
|
74
|
+
printf '%s\n' "${1//${WEBHOOK_SECRET}/<secret>}"
|
|
75
|
+
else
|
|
76
|
+
printf '%s\n' "$1"
|
|
77
|
+
fi
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Every mutating gcloud call goes through here: --dry-run turns the whole script into a
|
|
81
|
+
# description of itself. stdin is closed so a gcloud call inside a read loop cannot swallow
|
|
82
|
+
# the lines the loop has not read yet. Output is captured rather than streamed so a failure
|
|
83
|
+
# can be redacted before it reaches the terminal.
|
|
84
|
+
run() {
|
|
85
|
+
if [ "${DRY_RUN}" = true ]; then
|
|
86
|
+
redact " (dry run) $*"
|
|
87
|
+
return 0
|
|
88
|
+
fi
|
|
89
|
+
local out
|
|
90
|
+
if out=$("$@" </dev/null 2>&1); then
|
|
91
|
+
[ -n "${out}" ] && redact "${out}"
|
|
92
|
+
return 0
|
|
93
|
+
fi
|
|
94
|
+
redact "${out}" >&2
|
|
95
|
+
return 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
echo "🔧 Enabling APIs..."
|
|
99
|
+
run ${GC} services enable cloudscheduler.googleapis.com cloudtasks.googleapis.com --quiet
|
|
100
|
+
|
|
101
|
+
echo "🔎 Reading the deployed service..."
|
|
102
|
+
SERVICE_JSON=$(${GC} run services describe "${SERVICE}" --region="${REGION}" --format=json) || {
|
|
103
|
+
echo "Could not describe ${SERVICE} in ${PROJECT}/${REGION} — deploy the engine first." >&2
|
|
104
|
+
exit 1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
svc_field() {
|
|
108
|
+
python3 -c "
|
|
109
|
+
import json, sys
|
|
110
|
+
svc = json.loads(sys.stdin.read())
|
|
111
|
+
spec = svc['spec']['template']['spec']
|
|
112
|
+
env = {e['name']: e for e in spec['containers'][0].get('env', [])}
|
|
113
|
+
what = sys.argv[1]
|
|
114
|
+
if what == 'url':
|
|
115
|
+
print((env.get('AGENT_PUBLIC_URL', {}).get('value')) or svc['status']['url'])
|
|
116
|
+
elif what == 'sa':
|
|
117
|
+
print(spec.get('serviceAccountName') or '')
|
|
118
|
+
elif what == 'scaling':
|
|
119
|
+
ann = svc['spec']['template']['metadata'].get('annotations', {})
|
|
120
|
+
print('{}\t{}'.format(ann.get('autoscaling.knative.dev/minScale', '0'),
|
|
121
|
+
ann.get('run.googleapis.com/cpu-throttling', 'true')))
|
|
122
|
+
else:
|
|
123
|
+
e = env.get(what, {})
|
|
124
|
+
if e.get('value'):
|
|
125
|
+
print('value\t' + e['value'])
|
|
126
|
+
else:
|
|
127
|
+
ref = e.get('valueFrom', {}).get('secretKeyRef', {})
|
|
128
|
+
print('secret\t{}\t{}'.format(ref.get('name', ''), ref.get('key') or 'latest'))
|
|
129
|
+
" "$1"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
AGENT_URL=$(printf '%s' "${SERVICE_JSON}" | svc_field url)
|
|
133
|
+
RUNTIME_SA=$(printf '%s' "${SERVICE_JSON}" | svc_field sa)
|
|
134
|
+
if [ -z "${RUNTIME_SA}" ]; then
|
|
135
|
+
PROJECT_NUMBER=$(${GC} projects describe "${PROJECT}" --format='value(projectNumber)')
|
|
136
|
+
RUNTIME_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# The triggers authenticate with the same bearer token as everything else on this API, so
|
|
140
|
+
# the secret has to be resolved — it is normally a Secret Manager reference on the service.
|
|
141
|
+
SECRET_REF=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_WEBHOOK_SECRET)
|
|
142
|
+
IFS=$'\t' read -r SECRET_KIND SECRET_A SECRET_B <<<"${SECRET_REF}"
|
|
143
|
+
if [ "${SECRET_KIND}" = "value" ]; then
|
|
144
|
+
WEBHOOK_SECRET="${SECRET_A}"
|
|
145
|
+
elif [ "${SECRET_KIND}" = "secret" ] && [ -n "${SECRET_A}" ]; then
|
|
146
|
+
WEBHOOK_SECRET=$(${GC} secrets versions access "${SECRET_B}" --secret="${SECRET_A}")
|
|
147
|
+
else
|
|
148
|
+
echo "Could not resolve AGENT_WEBHOOK_SECRET from ${SERVICE} — is it set on the service?" >&2
|
|
149
|
+
echo " scripts/set-secret.sh AGENT_WEBHOOK_SECRET --random" >&2
|
|
150
|
+
exit 1
|
|
151
|
+
fi
|
|
152
|
+
[ -n "${WEBHOOK_SECRET}" ] || { echo "AGENT_WEBHOOK_SECRET resolved empty — refusing to create triggers that would 401." >&2; exit 1; }
|
|
153
|
+
|
|
154
|
+
echo " url: ${AGENT_URL}"
|
|
155
|
+
echo " runtime: ${RUNTIME_SA}"
|
|
156
|
+
|
|
157
|
+
IFS=$'\t' read -r MIN_SCALE CPU_THROTTLING <<<"$(printf '%s' "${SERVICE_JSON}" | svc_field scaling)"
|
|
158
|
+
if [ "${MIN_SCALE}" != "0" ] || [ "${CPU_THROTTLING}" = "false" ]; then
|
|
159
|
+
cat >&2 <<EOF
|
|
160
|
+
|
|
161
|
+
⚠️ ${SERVICE} still runs always-on (min-instances=${MIN_SCALE}, cpu-throttling=${CPU_THROTTLING}).
|
|
162
|
+
External triggers work either way, but the saving does not arrive until the service
|
|
163
|
+
scales to zero — and until then in-process timers and Cloud Scheduler would BOTH fire,
|
|
164
|
+
so every cron runs twice. The shape lives in deployment.env and deploy.sh asserts it:
|
|
165
|
+
|
|
166
|
+
SCALING=scale-to-zero # in deployment.env, then
|
|
167
|
+
scripts/deploy.sh # rolls it out (--dry-run to see the shape first)
|
|
168
|
+
|
|
169
|
+
EOF
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
echo "📥 Cloud Tasks queue ${QUEUE}..."
|
|
173
|
+
if ! ${GC} tasks queues describe "${QUEUE}" --location="${REGION}" >/dev/null 2>&1; then
|
|
174
|
+
run ${GC} tasks queues create "${QUEUE}" --location="${REGION}" --quiet
|
|
175
|
+
fi
|
|
176
|
+
# max-concurrent-dispatches leaves room for several held attempts at once; the engine's own
|
|
177
|
+
# queue is serial, so this bounds requests waiting, not jobs running.
|
|
178
|
+
run ${GC} tasks queues update "${QUEUE}" --location="${REGION}" \
|
|
179
|
+
--max-attempts=4 --min-backoff=5s --max-backoff=30s --max-doublings=2 \
|
|
180
|
+
--max-concurrent-dispatches=8 --max-dispatches-per-second=2 --quiet
|
|
181
|
+
run ${GC} tasks queues add-iam-policy-binding "${QUEUE}" --location="${REGION}" \
|
|
182
|
+
--member="serviceAccount:${RUNTIME_SA}" --role="roles/cloudtasks.enqueuer" --quiet
|
|
183
|
+
|
|
184
|
+
CURRENT_QUEUE=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_TASKS_QUEUE | cut -f2)
|
|
185
|
+
CURRENT_URL=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_PUBLIC_URL | cut -f2)
|
|
186
|
+
if [ "${CURRENT_QUEUE}" != "${QUEUE_PATH}" ] || [ "${CURRENT_URL}" != "${AGENT_URL}" ]; then
|
|
187
|
+
echo "⚙️ Switching ${SERVICE} to external triggers (new revision)..."
|
|
188
|
+
run ${GC} run services update "${SERVICE}" --region="${REGION}" \
|
|
189
|
+
--update-env-vars="AGENT_TASKS_QUEUE=${QUEUE_PATH},AGENT_PUBLIC_URL=${AGENT_URL}" --quiet
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
echo "📋 Reading the job list from ${AGENT_URL}/jobs..."
|
|
193
|
+
JOBS_JSON=$(curl -fsS --max-time 300 -H "Authorization: Bearer ${WEBHOOK_SECRET}" "${AGENT_URL}/jobs") || {
|
|
194
|
+
cat >&2 <<EOF
|
|
195
|
+
Could not read ${AGENT_URL}/jobs.
|
|
196
|
+
|
|
197
|
+
The engine is the only thing that knows which jobs exist — it reads them from a boot-time
|
|
198
|
+
clone of the content repo. So the service has to be deployed and healthy first:
|
|
199
|
+
|
|
200
|
+
curl -fsS ${AGENT_URL}/health
|
|
201
|
+
scripts/deploy.sh
|
|
202
|
+
|
|
203
|
+
A first request after a scale-to-zero idle also has to wait out a cold start, which
|
|
204
|
+
includes cloning the content repo.
|
|
205
|
+
EOF
|
|
206
|
+
exit 1
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# name<TAB>cron for every job that declares one. A disabled job keeps its trigger: whether
|
|
210
|
+
# it runs is re-read from the content repo per run, which is what makes `disabled: true`
|
|
211
|
+
# push-effective rather than a redeploy.
|
|
212
|
+
CRON_JOBS=$(printf '%s' "${JOBS_JSON}" | python3 -c "
|
|
213
|
+
import json, sys
|
|
214
|
+
for job in json.load(sys.stdin):
|
|
215
|
+
if job.get('cron'):
|
|
216
|
+
print('{}\t{}'.format(job['name'], job['cron']))
|
|
217
|
+
")
|
|
218
|
+
WATCHES_INBOX=$(printf '%s' "${JOBS_JSON}" | python3 -c "
|
|
219
|
+
import json, sys
|
|
220
|
+
print('yes' if any(j.get('inbox') for j in json.load(sys.stdin)) else 'no')
|
|
221
|
+
")
|
|
222
|
+
|
|
223
|
+
HEADERS="Content-Type=application/json,Authorization=Bearer ${WEBHOOK_SECRET}"
|
|
224
|
+
|
|
225
|
+
# `create http` takes --headers; `update http` insists on --update-headers for the same
|
|
226
|
+
# thing. Everything else about the two calls is identical.
|
|
227
|
+
upsert_job() {
|
|
228
|
+
local name="$1" schedule="$2" uri="$3"
|
|
229
|
+
local common=(
|
|
230
|
+
--location="${REGION}"
|
|
231
|
+
--schedule="${schedule}" --time-zone="${TIMEZONE}"
|
|
232
|
+
--uri="${uri}" --http-method=POST --message-body={}
|
|
233
|
+
--attempt-deadline="${ATTEMPT_DEADLINE}"
|
|
234
|
+
--max-retry-attempts=4 --min-backoff=5s --max-backoff=30s --max-doublings=2
|
|
235
|
+
--quiet
|
|
236
|
+
)
|
|
237
|
+
if ${GC} scheduler jobs describe "${name}" --location="${REGION}" >/dev/null 2>&1; then
|
|
238
|
+
run ${GC} scheduler jobs update http "${name}" "${common[@]}" --update-headers="${HEADERS}" >/dev/null
|
|
239
|
+
echo " updated ${name} [${schedule}]"
|
|
240
|
+
else
|
|
241
|
+
run ${GC} scheduler jobs create http "${name}" "${common[@]}" --headers="${HEADERS}" >/dev/null
|
|
242
|
+
echo " created ${name} [${schedule}]"
|
|
243
|
+
fi
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
echo "⏰ Cloud Scheduler jobs..."
|
|
247
|
+
WANTED=""
|
|
248
|
+
while IFS=$'\t' read -r name cron; do
|
|
249
|
+
[ -z "${name}" ] && continue
|
|
250
|
+
WANTED="${WANTED} ${JOB_PREFIX}${name}"
|
|
251
|
+
upsert_job "${JOB_PREFIX}${name}" "${cron}" "${AGENT_URL}/jobs/${name}/run?source=scheduler"
|
|
252
|
+
done <<<"${CRON_JOBS}"
|
|
253
|
+
|
|
254
|
+
# An inbox is only as fresh as the sweep that checks it, so the sweep inherits the poll
|
|
255
|
+
# interval when one is being watched. With no inbox job there is nothing time-sensitive left
|
|
256
|
+
# in it — due follow-ups get their own task — so hourly is enough.
|
|
257
|
+
if [ "${WATCHES_INBOX}" = "yes" ]; then
|
|
258
|
+
POLL_SECONDS=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_INBOX_POLL_SECONDS | cut -f2)
|
|
259
|
+
POLL_MINUTES=$(( (${POLL_SECONDS:-300} + 59) / 60 ))
|
|
260
|
+
[ "${POLL_MINUTES}" -lt 1 ] && POLL_MINUTES=1
|
|
261
|
+
if [ "${POLL_MINUTES}" -ge 60 ]; then
|
|
262
|
+
SWEEP_SCHEDULE="17 * * * *"
|
|
263
|
+
else
|
|
264
|
+
SWEEP_SCHEDULE="*/${POLL_MINUTES} * * * *"
|
|
265
|
+
fi
|
|
266
|
+
else
|
|
267
|
+
SWEEP_SCHEDULE="17 * * * *"
|
|
268
|
+
fi
|
|
269
|
+
upsert_job "${SWEEP_JOB}" "${SWEEP_SCHEDULE}" "${AGENT_URL}/internal/sweep"
|
|
270
|
+
|
|
271
|
+
EXISTING=$(${GC} scheduler jobs list --location="${REGION}" --format='value(ID)' | grep "^${JOB_PREFIX}" || true)
|
|
272
|
+
for existing in ${EXISTING}; do
|
|
273
|
+
keep=false
|
|
274
|
+
for wanted in ${WANTED}; do
|
|
275
|
+
[ "${existing}" = "${wanted}" ] && keep=true
|
|
276
|
+
done
|
|
277
|
+
if [ "${keep}" = false ]; then
|
|
278
|
+
run ${GC} scheduler jobs delete "${existing}" --location="${REGION}" --quiet
|
|
279
|
+
echo " deleted ${existing} (no job declares it any more)"
|
|
280
|
+
fi
|
|
281
|
+
done
|
|
282
|
+
|
|
283
|
+
COUNT=$(printf '%s' "${WANTED}" | wc -w | tr -d ' ')
|
|
284
|
+
echo ""
|
|
285
|
+
echo "✅ ${COUNT} cron trigger(s) + ${SWEEP_JOB} [${SWEEP_SCHEDULE}] in place."
|
|
286
|
+
echo " Inspect: gcloud scheduler jobs list --project ${PROJECT} --location ${REGION}"
|
|
287
|
+
echo " Sweep now: gcloud scheduler jobs run ${SWEEP_JOB} --project ${PROJECT} --location ${REGION}"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Refresh this deployment's operator tooling from a published engine image.
|
|
5
|
+
#
|
|
6
|
+
# The engine's source repo is private; the image is not. Everything an operator needs —
|
|
7
|
+
# these scripts, the set-up guide, the config template — ships inside
|
|
8
|
+
# ghcr.io/meffecta/agent, so a deployment run by someone outside Meffecta gets its tooling
|
|
9
|
+
# from the same public artifact it gets its engine from, and never needs the source.
|
|
10
|
+
#
|
|
11
|
+
# The tooling belongs in your content repo, committed next to jobs/ and deployment.env.
|
|
12
|
+
# That is what makes a fresh clone of that repo enough to operate the deployment, and it
|
|
13
|
+
# means `git diff` after this shows exactly what changed in the tooling between engine
|
|
14
|
+
# versions — worth a glance before you deploy the matching image.
|
|
15
|
+
#
|
|
16
|
+
# Getting it the first time, when you have no scripts yet (from your content repo):
|
|
17
|
+
#
|
|
18
|
+
# cid=$(docker create ghcr.io/meffecta/agent:latest) \
|
|
19
|
+
# && docker cp "$cid:/app/scripts" . \
|
|
20
|
+
# && docker rm "$cid" >/dev/null
|
|
21
|
+
#
|
|
22
|
+
# That is this script's job, done by hand once. Afterwards:
|
|
23
|
+
#
|
|
24
|
+
# scripts/update-tooling.sh # match the newest published engine
|
|
25
|
+
# scripts/update-tooling.sh --tag sha-558a144 # match a specific one, e.g. what you run
|
|
26
|
+
# scripts/update-tooling.sh --dry-run # show what would change, touch nothing
|
|
27
|
+
#
|
|
28
|
+
# Needs docker, which the default deploy path needs anyway (it mirrors the image into your
|
|
29
|
+
# Artifact Registry). A deployment using an AR remote repository instead still needs it
|
|
30
|
+
# here — once per engine version, rather than once per deploy.
|
|
31
|
+
|
|
32
|
+
GHCR_IMAGE="${GHCR_IMAGE:-ghcr.io/meffecta/agent}"
|
|
33
|
+
TAG="latest"
|
|
34
|
+
IMAGE=""
|
|
35
|
+
DRY_RUN=false
|
|
36
|
+
DEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
37
|
+
|
|
38
|
+
while [ $# -gt 0 ]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--tag) TAG="${2:?--tag needs a value}"; shift 2 ;;
|
|
41
|
+
--image) IMAGE="${2:?--image needs a value}"; shift 2 ;;
|
|
42
|
+
--into) DEST="${2:?--into needs a directory}"; shift 2 ;;
|
|
43
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
44
|
+
-h|--help) sed -n '3,30p' "$0"; exit 0 ;;
|
|
45
|
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
46
|
+
esac
|
|
47
|
+
done
|
|
48
|
+
|
|
49
|
+
command -v docker >/dev/null || {
|
|
50
|
+
echo "docker is required to read the engine image." >&2
|
|
51
|
+
exit 1
|
|
52
|
+
}
|
|
53
|
+
[ -n "${IMAGE}" ] || IMAGE="${GHCR_IMAGE}:${TAG}"
|
|
54
|
+
|
|
55
|
+
echo "▶ Updating operator tooling"
|
|
56
|
+
echo " from: ${IMAGE}"
|
|
57
|
+
echo " into: ${DEST}"
|
|
58
|
+
echo ""
|
|
59
|
+
|
|
60
|
+
TMP="$(mktemp -d)"
|
|
61
|
+
trap 'rm -rf "${TMP}"' EXIT
|
|
62
|
+
|
|
63
|
+
# --platform pins the amd64 image Cloud Run runs; nothing here executes it, and naming
|
|
64
|
+
# it keeps docker from warning about the mismatch on an arm64 laptop.
|
|
65
|
+
docker pull --quiet --platform linux/amd64 "${IMAGE}" >/dev/null || {
|
|
66
|
+
echo "Could not pull ${IMAGE}. The engine image is public, so check the tag exists and" >&2
|
|
67
|
+
echo "that this host can reach ghcr.io." >&2
|
|
68
|
+
exit 1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
CID="$(docker create --platform linux/amd64 "${IMAGE}")"
|
|
72
|
+
# `docker cp` copies out as the host user, so nothing depends on the uid inside the image.
|
|
73
|
+
docker cp "${CID}:/app/scripts" "${TMP}/scripts" >/dev/null
|
|
74
|
+
for doc in IMPLEMENTATION.md ARCHITECTURE.md deployment.env.example; do
|
|
75
|
+
docker cp "${CID}:/app/${doc}" "${TMP}/scripts/${doc}" >/dev/null 2>&1 || true
|
|
76
|
+
done
|
|
77
|
+
docker rm "${CID}" >/dev/null
|
|
78
|
+
|
|
79
|
+
CHANGED=0
|
|
80
|
+
NEW=0
|
|
81
|
+
for file in "${TMP}/scripts"/* "${TMP}/scripts"/*/*; do
|
|
82
|
+
[ -f "${file}" ] || continue
|
|
83
|
+
rel="${file#"${TMP}/scripts/"}"
|
|
84
|
+
if [ ! -f "${DEST}/${rel}" ]; then
|
|
85
|
+
echo " + ${rel}"
|
|
86
|
+
NEW=$((NEW + 1))
|
|
87
|
+
elif ! cmp -s "${file}" "${DEST}/${rel}"; then
|
|
88
|
+
echo " ~ ${rel}"
|
|
89
|
+
CHANGED=$((CHANGED + 1))
|
|
90
|
+
fi
|
|
91
|
+
done
|
|
92
|
+
|
|
93
|
+
# Anything here that the image does not carry: yours, or left over from an older engine.
|
|
94
|
+
# Reported rather than removed — this directory is in your repo, not the engine's.
|
|
95
|
+
for file in "${DEST}"/*.sh "${DEST}"/*.mjs; do
|
|
96
|
+
[ -f "${file}" ] || continue
|
|
97
|
+
rel="$(basename "${file}")"
|
|
98
|
+
[ -f "${TMP}/scripts/${rel}" ] || echo " ? ${rel} (not in this engine version — yours to keep or delete)"
|
|
99
|
+
done
|
|
100
|
+
|
|
101
|
+
if [ "${NEW}" -eq 0 ] && [ "${CHANGED}" -eq 0 ]; then
|
|
102
|
+
echo " already up to date"
|
|
103
|
+
exit 0
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
echo ""
|
|
107
|
+
echo " ${NEW} new, ${CHANGED} changed"
|
|
108
|
+
if [ "${DRY_RUN}" = true ]; then
|
|
109
|
+
echo " (dry run: nothing was changed)"
|
|
110
|
+
exit 0
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# This script is one of the files being overwritten, and bash reads a script as it runs it.
|
|
114
|
+
# exec hands the process over so nothing further is read from the file underneath us.
|
|
115
|
+
echo "✅ Updated. Review with \`git diff\`, then deploy the matching engine:"
|
|
116
|
+
echo " scripts/deploy.sh${TAG:+ --tag ${TAG}}"
|
|
117
|
+
exec cp -R "${TMP}/scripts/." "${DEST}/"
|