@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.
@@ -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}/"