@meffecta/agent 1.0.3 → 1.0.8

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,55 @@
1
+ # What to call the next command, in output an operator reads. Sourced, never run.
2
+ #
3
+ # Almost every operator reaches these scripts through the CLI — `meffecta-agent deploy` —
4
+ # and has never seen a .sh file: the engine's repo is private, so a deployment run by
5
+ # someone outside Meffecta cannot clone it, and the scripts arrive vendored inside the npm
6
+ # package or lifted out of the image. Printing "run ./scripts/deploy.sh next" names a file
7
+ # they do not have.
8
+ #
9
+ # The CLI exports MEFFECTA_CLI with the invocation it was reached by, which is both the
10
+ # name to print and the proof that a CLI is there at all. Without it, the operator is
11
+ # running the file directly and the filename IS the answer.
12
+ #
13
+ # This is its own lib rather than part of deployment.sh because create-project.sh and
14
+ # update-tooling.sh deliberately avoid the deployment config — they run before there is
15
+ # one, or beside it — and still have to name their next step.
16
+
17
+ CLI_LIB_SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
18
+
19
+ # cmd <subcommand> → "meffecta-agent deploy", or "/path/to/scripts/deploy.sh"
20
+ cmd() {
21
+ if [ -n "${MEFFECTA_CLI:-}" ]; then
22
+ printf '%s %s' "${MEFFECTA_CLI}" "$1"
23
+ return
24
+ fi
25
+ local file
26
+ case "$1" in
27
+ setup-infra) file="setup-infrastructure.sh" ;;
28
+ artifact-cleanup) file="set-artifact-cleanup.sh" ;;
29
+ mint-gmail) file="mint-gmail-token.mjs" ;;
30
+ mint-graph) file="mint-graph-token.mjs" ;;
31
+ verify-credentials) file="verify-credentials.mjs" ;;
32
+ *) file="$1.sh" ;;
33
+ esac
34
+ printf '%s/%s' "${CLI_LIB_SCRIPTS_DIR}" "${file}"
35
+ }
36
+
37
+ # The same for the running script naming itself — "run this again from there". $0 is an
38
+ # absolute path inside node_modules when the CLI spawned it, which is not a thing anyone
39
+ # would type.
40
+ self_cmd() {
41
+ [ -n "${MEFFECTA_CLI:-}" ] || { printf '%s' "$0"; return; }
42
+ local base
43
+ base="$(basename "$0")"
44
+ case "${base}" in
45
+ setup-infrastructure.sh) printf '%s setup-infra' "${MEFFECTA_CLI}" ;;
46
+ set-artifact-cleanup.sh) printf '%s artifact-cleanup' "${MEFFECTA_CLI}" ;;
47
+ mint-gmail-token.mjs) printf '%s mint-gmail' "${MEFFECTA_CLI}" ;;
48
+ mint-graph-token.mjs) printf '%s mint-graph' "${MEFFECTA_CLI}" ;;
49
+ *) printf '%s %s' "${MEFFECTA_CLI}" "${base%.*}" ;;
50
+ esac
51
+ }
52
+
53
+ # True when the operator has the CLI, i.e. when there is a `meffecta-agent doctor` to point
54
+ # at. Guards the lines that only make sense with one.
55
+ has_cli() { [ -n "${MEFFECTA_CLI:-}" ]; }
@@ -22,6 +22,9 @@
22
22
  # Config file format is plain KEY=value lines, # comments allowed. See
23
23
  # deployment.env.example.
24
24
 
25
+ # What to call the next command in output: cmd / self_cmd / has_cli.
26
+ . "$(dirname "${BASH_SOURCE[0]}")/cli-names.sh"
27
+
25
28
  # Consume --config <file> from a script's arguments before it parses its own.
26
29
  # Usage: eval "$(extract_config_flag "$@")" — sets CONFIG_FILE and rewrites "$@".
27
30
  CONFIG_FILE=""
@@ -62,6 +65,13 @@ resolve_deployment() {
62
65
  REGION="${REGION:-europe-west1}"
63
66
  ARTIFACT_REPO="${ARTIFACT_REPO:-${SERVICE:-agent}-images}"
64
67
  GHCR_IMAGE="${GHCR_IMAGE:-ghcr.io/meffecta/agent}"
68
+ # How the engine image reaches Cloud Run, which can only ever pull from Artifact
69
+ # Registry. `proxy` makes ARTIFACT_REPO a remote repository in front of ghcr.io, so AR
70
+ # fetches on demand and no local docker is involved at all. `mirror` is the older path:
71
+ # pull from ghcr.io on the operator's machine, retag, push — which needs docker, and
72
+ # pushes several gigabytes for every engine version.
73
+ REGISTRY_MODE="${REGISTRY_MODE:-proxy}"
74
+ GHCR_PATH="${GHCR_IMAGE#*/}" # ghcr.io/meffecta/agent → meffecta/agent
65
75
  TIMEZONE="${TIMEZONE:-Europe/Stockholm}"
66
76
 
67
77
  # The runtime shape, asserted by deploy.sh on every rollout so the deployed service
@@ -85,10 +95,15 @@ No deployment.env found in $(pwd).
85
95
  That file lives in the deployment's content repo (committed — none of it is
86
96
  secret), so the usual fix is to run this from there:
87
97
 
88
- cd /path/to/your-content-repo && ${0}
98
+ cd /path/to/your-content-repo && $(self_cmd)
89
99
 
90
- Creating one: copy scripts/deployment.env.example beside your jobs/. Or point
91
- at a file explicitly with --config /path/to/deployment.env.
100
+ $(if has_cli; then
101
+ echo "Creating one: ${MEFFECTA_CLI} init, in that repo."
102
+ else
103
+ echo "Creating one: copy deployment.env.example (it ships beside these scripts)"
104
+ echo "beside your jobs/."
105
+ fi)
106
+ Or point at a file explicitly with --config /path/to/deployment.env.
92
107
  EOF
93
108
  exit 2
94
109
  fi
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Pull single values out of the JSON that setup-scheduler.sh reads from gcloud and from the
2
+ // Pull single values out of the JSON that sync-triggers.sh reads from gcloud and from the
3
3
  // running service. Shell cannot parse JSON and node is already required by the operator
4
4
  // tooling (mint-gmail-token.mjs, verify-credentials.mjs), so this replaces what used to be
5
5
  // inline python3 — one fewer thing a deployment has to have installed.
@@ -0,0 +1,24 @@
1
+ # The Cloud Tasks queue that carries a scale-to-zero deployment's work back to it.
2
+ #
3
+ # Two scripts create it, which is why it lives here rather than in either of them.
4
+ # setup-infrastructure.sh provisions it with the deployment's other static resources — it
5
+ # is ordinary infrastructure, and knows nothing about jobs. sync-triggers.sh ensures it
6
+ # too, for a deployment provisioned before that was true and for the standalone re-sync.
7
+ # Duplicating the settings in both would mean a deployment's retry behaviour depending on
8
+ # which script last touched it.
9
+ #
10
+ # From the caller: GC (gcloud with --project), REGION, RUNTIME_SA, and a run() wrapper that
11
+ # honours DRY_RUN.
12
+ ensure_tasks_queue() {
13
+ local queue="$1"
14
+ if ! ${GC} tasks queues describe "${queue}" --location="${REGION}" >/dev/null 2>&1; then
15
+ run ${GC} tasks queues create "${queue}" --location="${REGION}" --quiet
16
+ fi
17
+ # max-concurrent-dispatches leaves room for several held attempts at once; the engine's
18
+ # own queue is serial, so this bounds requests waiting, not jobs running.
19
+ run ${GC} tasks queues update "${queue}" --location="${REGION}" \
20
+ --max-attempts=4 --min-backoff=5s --max-backoff=30s --max-doublings=2 \
21
+ --max-concurrent-dispatches=8 --max-dispatches-per-second=2 --quiet
22
+ run ${GC} tasks queues add-iam-policy-binding "${queue}" --location="${REGION}" \
23
+ --member="serviceAccount:${RUNTIME_SA}" --role="roles/cloudtasks.enqueuer" --quiet
24
+ }
@@ -136,7 +136,7 @@ if [ "$(gcloud billing projects describe "${PROJECT}" --format='value(billingEna
136
136
  echo "✔ billingEnabled: true"
137
137
  echo ""
138
138
  echo "Next: create the content repo (step 3), then provision from inside it —"
139
- echo " $(dirname "$0")/setup-infrastructure.sh"
139
+ echo " $(cmd setup-infra)"
140
140
  else
141
141
  echo "✖ Linked, but billingEnabled is still false — check the account is open and funded." >&2
142
142
  exit 1
@@ -16,7 +16,7 @@
16
16
  // Sign in as WHICHEVER account the token is for when the browser opens — run once
17
17
  // per account (private Gmail → GMAIL_REFRESH_TOKEN; additional accounts →
18
18
  // --account NAME → GMAIL_<NAME>_REFRESH_TOKEN). --readonly mints read scopes only,
19
- // which is the right level for an account Marcus doesn't administer alone (a
19
+ // which is the right level for an account this deployment doesn't administer alone (a
20
20
  // company mailbox: read the mail, never send from that identity).
21
21
  // Gmail scopes are "restricted", so an unverified app
22
22
  // shows a warning — Advanced → continue is fine for your own account. A Workspace
@@ -120,7 +120,12 @@ if (!res.ok || !json.refresh_token) {
120
120
  process.exit(1);
121
121
  }
122
122
 
123
+ // How the operator reached this script, so the line telling them to pipe it into
124
+ // gcloud is one they can actually type. MEFFECTA_CLI is set when the CLI spawned us;
125
+ // without it they ran the file directly, and the file path is the honest answer.
126
+ const self = process.env.MEFFECTA_CLI ? `${process.env.MEFFECTA_CLI} mint-gmail` : `node ${process.argv[1]}`;
127
+
123
128
  // Refresh token to stdout (pipe straight into gcloud), everything else to stderr.
124
129
  console.error(`\nRefresh token minted — it is ${tokenVar}. Store it without it touching your shell history:`);
125
- console.error(` node scripts/mint-gmail-token.mjs ... | gcloud secrets create ${tokenVar} --data-file=-\n`);
130
+ console.error(` ${self} ... | gcloud secrets create ${tokenVar} --data-file=-\n`);
126
131
  console.log(json.refresh_token);
@@ -26,7 +26,7 @@
26
26
  // 4. Note the Application (client) ID and the Directory (tenant) ID.
27
27
  //
28
28
  // Usage:
29
- // node scripts/mint-graph-token.mjs --account MOXSEA --tenant <tenant-id> \
29
+ // node scripts/mint-graph-token.mjs --account WORK --tenant <tenant-id> \
30
30
  // --client-id <id> --client-secret <secret> [--no-calendar]
31
31
  //
32
32
  // Sign in as the mailbox the token is for when the browser opens — the everyday
@@ -146,7 +146,12 @@ if (!res.ok || !json.refresh_token) {
146
146
  process.exit(1);
147
147
  }
148
148
 
149
+ // How the operator reached this script, so the line telling them to pipe it into
150
+ // gcloud is one they can actually type. MEFFECTA_CLI is set when the CLI spawned us;
151
+ // without it they ran the file directly, and the file path is the honest answer.
152
+ const self = process.env.MEFFECTA_CLI ? `${process.env.MEFFECTA_CLI} mint-graph` : `node ${process.argv[1]}`;
153
+
149
154
  // Refresh token to stdout (pipe straight into gcloud), everything else to stderr.
150
155
  console.error(`\nRefresh token minted — it is ${tokenVar}. Store it without it touching your shell history:`);
151
- console.error(` node scripts/mint-graph-token.mjs ... | gcloud secrets create ${tokenVar} --data-file=-\n`);
156
+ console.error(` ${self} ... | gcloud secrets create ${tokenVar} --data-file=-\n`);
152
157
  console.log(json.refresh_token);
@@ -35,7 +35,7 @@ while [ $# -gt 0 ]; do
35
35
  esac
36
36
  done
37
37
 
38
- [ -n "${NAME}" ] || { echo "Which secret? e.g. scripts/set-secret.sh GITHUB_TOKEN" >&2; exit 2; }
38
+ [ -n "${NAME}" ] || { echo "Which secret? e.g. $(self_cmd) GITHUB_TOKEN" >&2; exit 2; }
39
39
  case "${NAME}" in
40
40
  [A-Z]*[A-Z0-9]|[A-Z]) ;;
41
41
  *) echo "Secret names are UPPER_SNAKE_CASE: got '${NAME}'" >&2; exit 2 ;;
@@ -14,20 +14,79 @@ set -euo pipefail
14
14
  #
15
15
  # Which deployment it provisions comes from ./deployment.env (or --config <file>) —
16
16
  # see deployment.env.example. Nothing is defaulted to another deployment's project.
17
+ #
18
+ # ... setup-infra # provision
19
+ # ... setup-infra --dry-run # print every change it would make, touch nothing
20
+ #
21
+ # --dry-run still READS the project, so it reports what it would do to the project you
22
+ # actually have — what is missing, what is already there — rather than a fixed list of
23
+ # steps. Nothing it prints needs billing, which is also what makes this path testable
24
+ # before anyone runs it for real.
17
25
 
18
26
  . "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
27
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/tasks-queue.sh"
28
+
29
+ DRY_RUN=false
19
30
 
20
31
  while [ $# -gt 0 ]; do
21
32
  case "$1" in
22
33
  --config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
34
+ --dry-run) DRY_RUN=true; shift ;;
23
35
  -h|--help) sed -n '4,25p' "$0"; exit 0 ;;
24
36
  *) echo "Unknown argument: $1" >&2; exit 2 ;;
25
37
  esac
26
38
  done
27
39
 
40
+ # Every call that CHANGES something goes through this; the describe calls that ask what is
41
+ # already there do not, so a dry run reads a real project and reports what it would do to
42
+ # it rather than a fixed script of steps.
43
+ #
44
+ # This is the script that creates a service account and grants it roles inside someone
45
+ # else's GCP project, run by an operator who cannot read it — it is published tooling, and
46
+ # the repo behind it is private. Being able to see exactly what it will do, first, is the
47
+ # least a vendor can offer for that. It also makes the client path testable without a
48
+ # project, which is the only way it gets exercised at all before a client runs it.
49
+ run() {
50
+ if [ "${DRY_RUN}" = true ]; then
51
+ echo " (dry run) $*"
52
+ return 0
53
+ fi
54
+ "$@"
55
+ }
56
+
57
+ # The same, for calls whose success output is a full IAM policy dump nobody reads. The
58
+ # redirect has to live HERE rather than on the call, or it swallows the dry-run line too —
59
+ # which it did, leaving every IAM grant, the most consequential thing this script does, as
60
+ # the one thing a dry run did not show.
61
+ run_quiet() {
62
+ if [ "${DRY_RUN}" = true ]; then
63
+ echo " (dry run) $*"
64
+ return 0
65
+ fi
66
+ "$@" >/dev/null
67
+ }
68
+
69
+ # Deploys are not wired to the engine repo's CI: it publishes images to GHCR, and a
70
+ # deployment rolls one out when it chooses. The deployer SA + Workload Identity
71
+ # Federation below are therefore optional — provision them only if THIS deployment wants
72
+ # a CI deploy job of its own, from its own content repo:
73
+ # SETUP_CI_DEPLOY=true GITHUB_REPO=<owner>/<your-content-repo> ...
74
+ #
75
+ # GITHUB_REPO has NO DEFAULT, and that is the point. The value becomes a trust condition:
76
+ # the named repo's GitHub Actions may impersonate the deployer SA, which holds run.admin
77
+ # on this project. A default would hand that to whoever the default names — someone else
78
+ # entirely — on behalf of an operator who never typed a repo at all.
79
+ SETUP_CI_DEPLOY="${SETUP_CI_DEPLOY:-false}"
80
+ GITHUB_REPO="${GITHUB_REPO:-}" # owner/repo a CI deploy would run from
81
+ if [ "${SETUP_CI_DEPLOY}" = "true" ] && ! printf '%s' "${GITHUB_REPO}" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; then
82
+ echo "SETUP_CI_DEPLOY=true needs GITHUB_REPO=<owner>/<repo> — the repo allowed to deploy." >&2
83
+ echo "It is written into a trust condition, so there is nothing safe to guess." >&2
84
+ exit 2
85
+ fi
86
+
28
87
  require_gcloud
29
88
  resolve_deployment
30
- announce_target "Provisioning infrastructure"
89
+ announce_target "Provisioning infrastructure$([ "${DRY_RUN}" = true ] && echo " (dry run — reads only)")"
31
90
 
32
91
  ACCOUNT="${ACCOUNT:-}"
33
92
  MEMORY_BUCKET="${PROJECT}-memory"
@@ -36,22 +95,13 @@ RUNTIME_SA_NAME="agent-runtime"
36
95
  RUNTIME_SA="${RUNTIME_SA_NAME}@${PROJECT}.iam.gserviceaccount.com"
37
96
  DEPLOY_SA_NAME="github-deployer"
38
97
  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
98
  # ACCOUNT is optional: unset means whichever account gcloud is currently logged in as.
48
99
  GC="gcloud --project=${PROJECT}"
49
100
  [ -n "${ACCOUNT}" ] && GC="${GC} --account=${ACCOUNT}"
50
101
 
51
102
  echo "🔧 Enabling APIs..."
52
- ${GC} services enable \
103
+ run ${GC} services enable \
53
104
  run.googleapis.com \
54
- cloudbuild.googleapis.com \
55
105
  artifactregistry.googleapis.com \
56
106
  secretmanager.googleapis.com \
57
107
  iamcredentials.googleapis.com \
@@ -69,19 +119,50 @@ ${GC} services enable \
69
119
  analyticsdata.googleapis.com \
70
120
  iam.googleapis.com
71
121
 
72
- echo "🐳 Artifact Registry repo..."
122
+ echo "🐳 Artifact Registry repo (${REGISTRY_MODE})..."
73
123
  if ! ${GC} artifacts repositories describe "${ARTIFACT_REPO}" --location="${REGION}" &>/dev/null; then
74
- ${GC} artifacts repositories create "${ARTIFACT_REPO}" --location="${REGION}" --repository-format=docker
124
+ if [ "${REGISTRY_MODE}" = "proxy" ]; then
125
+ # A remote repository standing in front of ghcr.io. Cloud Run can only pull from
126
+ # Artifact Registry, and this is how that requirement is met without a local docker:
127
+ # AR fetches the public engine image itself, on demand, and caches it. No upstream
128
+ # credentials — the engine package is public, which is what makes this the simple path.
129
+ run ${GC} artifacts repositories create "${ARTIFACT_REPO}" --location="${REGION}" \
130
+ --repository-format=docker --mode=remote-repository \
131
+ --remote-docker-repo="https://${GHCR_IMAGE%%/*}" \
132
+ --description="Proxy of ${GHCR_IMAGE%%/*} — the engine image is fetched on demand"
133
+ else
134
+ run ${GC} artifacts repositories create "${ARTIFACT_REPO}" --location="${REGION}" --repository-format=docker
135
+ fi
136
+ else
137
+ ACTUAL_MODE=$(${GC} artifacts repositories describe "${ARTIFACT_REPO}" --location="${REGION}" --format='value(mode)')
138
+ case "${ACTUAL_MODE}:${REGISTRY_MODE}" in
139
+ REMOTE_REPOSITORY:proxy|STANDARD_REPOSITORY:mirror) ;;
140
+ *)
141
+ # A repository's mode is fixed at creation, so this is not something to "fix" here —
142
+ # deploy.sh would resolve an image path the repository cannot serve, and the failure
143
+ # would arrive as a Cloud Run pull error with nothing pointing back to this.
144
+ echo "⚠ ${ARTIFACT_REPO} is a ${ACTUAL_MODE} but REGISTRY_MODE=${REGISTRY_MODE}." >&2
145
+ echo " A repository's mode cannot be changed. Either set REGISTRY_MODE to match, or" >&2
146
+ echo " name a different ARTIFACT_REPO in deployment.env and re-run this." >&2
147
+ ;;
148
+ esac
75
149
  fi
76
150
  # The images here are a cache of ghcr.io/meffecta/agent, not an archive, so they are worth
77
151
  # 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
152
+ # Captured rather than streamed: gcloud narrates this one on stderr ("Updated repository",
153
+ # "Dry run is disabled"), which reads like something went wrong in the middle of a set-up.
154
+ # Housekeeping, so a failure is a warning rather than the end of provisioning.
155
+ if ! CLEANUP_LOG=$("$(dirname "${BASH_SOURCE[0]}")/set-artifact-cleanup.sh" ${CONFIG_FILE:+--config "${CONFIG_FILE}"} ${DRY_RUN:+$([ "${DRY_RUN}" = true ] && echo --dry-run)} 2>&1); then
156
+ echo "${CLEANUP_LOG}" >&2
157
+ echo "⚠ Could not set the registry cleanup policy — old images will accumulate." >&2
158
+ fi
79
159
 
80
160
  # --- Runtime service account (everything the agent itself can do) ---
81
161
  if ! ${GC} iam service-accounts describe "${RUNTIME_SA}" &>/dev/null; then
82
162
  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
163
+ run ${GC} iam service-accounts create "${RUNTIME_SA_NAME}" --display-name="Meffecta Agent"
164
+ # IAM takes a moment to propagate; a dry run has nothing to wait for.
165
+ [ "${DRY_RUN}" = true ] || for _ in $(seq 1 12); do
85
166
  ${GC} iam service-accounts describe "${RUNTIME_SA}" &>/dev/null && break
86
167
  sleep 5
87
168
  done
@@ -90,33 +171,20 @@ fi
90
171
  # --- Buckets: memory (mounted, agent-writable) + audit (write-once, never mounted) ---
91
172
  if ! ${GC} storage buckets describe "gs://${MEMORY_BUCKET}" &>/dev/null; then
92
173
  echo "🆕 Creating memory bucket: ${MEMORY_BUCKET}"
93
- ${GC} storage buckets create "gs://${MEMORY_BUCKET}" --location="${REGION}" --uniform-bucket-level-access
174
+ run ${GC} storage buckets create "gs://${MEMORY_BUCKET}" --location="${REGION}" --uniform-bucket-level-access
94
175
  fi
95
176
  if ! ${GC} storage buckets describe "gs://${AUDIT_BUCKET}" &>/dev/null; then
96
177
  echo "🆕 Creating audit bucket: ${AUDIT_BUCKET} (365d retention)"
97
- ${GC} storage buckets create "gs://${AUDIT_BUCKET}" --location="${REGION}" --uniform-bucket-level-access \
178
+ run ${GC} storage buckets create "gs://${AUDIT_BUCKET}" --location="${REGION}" --uniform-bucket-level-access \
98
179
  --retention-period=365d
99
180
  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
181
  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
182
+ run_quiet ${GC} storage buckets add-iam-policy-binding "gs://${MEMORY_BUCKET}" \
183
+ --member="serviceAccount:${RUNTIME_SA}" --role="roles/storage.objectAdmin" --quiet
184
+ run_quiet ${GC} storage buckets add-iam-policy-binding "gs://${AUDIT_BUCKET}" \
185
+ --member="serviceAccount:${RUNTIME_SA}" --role="roles/storage.objectCreator" --quiet
186
+ run_quiet ${GC} projects add-iam-policy-binding "${PROJECT}" \
187
+ --member="serviceAccount:${RUNTIME_SA}" --role="roles/secretmanager.secretAccessor" --quiet
120
188
 
121
189
  PROJECT_NUMBER=$(${GC} projects describe "${PROJECT}" --format='value(projectNumber)')
122
190
 
@@ -124,51 +192,46 @@ PROJECT_NUMBER=$(${GC} projects describe "${PROJECT}" --format='value(projectNum
124
192
  if [ "${SETUP_CI_DEPLOY}" = "true" ]; then
125
193
  if ! ${GC} iam service-accounts describe "${DEPLOY_SA}" &>/dev/null; then
126
194
  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
195
+ run ${GC} iam service-accounts create "${DEPLOY_SA_NAME}" --display-name="GitHub Actions deployer"
196
+ [ "${DRY_RUN}" = true ] || for _ in $(seq 1 12); do
129
197
  ${GC} iam service-accounts describe "${DEPLOY_SA}" &>/dev/null && break
130
198
  sleep 5
131
199
  done
132
200
  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
201
+ # What a DEPLOY needs, and nothing a BUILD would: roll out a revision, pull (or in mirror
202
+ # mode push) the engine image, and read this project's enabled services.
203
+ #
204
+ # Deliberately NOT granted: secretmanager.secretAccessor. deploy.sh re-syncs the Cloud
205
+ # Scheduler triggers, and that step reads the API secret — so a CI deploy runs
206
+ # `deploy --no-scheduler`, and an operator re-runs sync-triggers by hand when a cron
207
+ # changes. The alternative is a CI identity that can read every credential the deployment
208
+ # holds, which is a poor trade for skipping one occasional command.
209
+ for role in roles/run.admin roles/artifactregistry.writer roles/serviceusage.serviceUsageConsumer; do
210
+ run_quiet ${GC} projects add-iam-policy-binding "${PROJECT}" \
211
+ --member="serviceAccount:${DEPLOY_SA}" --role="${role}" --quiet
152
212
  done
213
+ # Deploying a service that runs as the runtime SA means acting as it, once.
214
+ run_quiet ${GC} iam service-accounts add-iam-policy-binding "${RUNTIME_SA}" \
215
+ --member="serviceAccount:${DEPLOY_SA}" --role="roles/iam.serviceAccountUser" --quiet
153
216
 
154
217
  POOL_ID="github-pool"
155
218
  PROVIDER_ID="github-provider"
156
219
  if ! ${GC} iam workload-identity-pools describe "${POOL_ID}" --location=global &>/dev/null; then
157
220
  echo "🆕 Creating Workload Identity pool + provider for GitHub Actions..."
158
- ${GC} iam workload-identity-pools create "${POOL_ID}" --location=global --display-name="GitHub Actions"
221
+ run ${GC} iam workload-identity-pools create "${POOL_ID}" --location=global --display-name="GitHub Actions"
159
222
  fi
160
223
  if ! ${GC} iam workload-identity-pools providers describe "${PROVIDER_ID}" \
161
224
  --location=global --workload-identity-pool="${POOL_ID}" &>/dev/null; then
162
- ${GC} iam workload-identity-pools providers create-oidc "${PROVIDER_ID}" \
225
+ run ${GC} iam workload-identity-pools providers create-oidc "${PROVIDER_ID}" \
163
226
  --location=global --workload-identity-pool="${POOL_ID}" \
164
227
  --display-name="GitHub OIDC" \
165
228
  --issuer-uri="https://token.actions.githubusercontent.com" \
166
229
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
167
230
  --attribute-condition="assertion.repository == '${GITHUB_REPO}'"
168
231
  fi
169
- ${GC} iam service-accounts add-iam-policy-binding "${DEPLOY_SA}" \
232
+ run_quiet ${GC} iam service-accounts add-iam-policy-binding "${DEPLOY_SA}" \
170
233
  --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
234
+ --role="roles/iam.workloadIdentityUser" --quiet
172
235
  else
173
236
  echo "⏭️ Skipping deployer SA + Workload Identity Federation (SETUP_CI_DEPLOY is not true)."
174
237
  fi
@@ -176,14 +239,44 @@ fi
176
239
  # --- Cloud Run service shell (placeholder image; scripts/deploy.sh rolls out a real one) ---
177
240
  if ! ${GC} run services describe "${SERVICE}" --region="${REGION}" &>/dev/null; then
178
241
  echo "🆕 Creating Cloud Run service: ${SERVICE}"
179
- ${GC} run services deploy "${SERVICE}" --region="${REGION}" \
242
+ run ${GC} run services deploy "${SERVICE}" --region="${REGION}" \
180
243
  --image="gcr.io/cloudrun/hello" --allow-unauthenticated \
181
244
  --service-account="${RUNTIME_SA}" 2>/dev/null ||
182
- ${GC} run deploy "${SERVICE}" --region="${REGION}" \
245
+ run ${GC} run deploy "${SERVICE}" --region="${REGION}" \
183
246
  --image="gcr.io/cloudrun/hello" --allow-unauthenticated \
184
247
  --service-account="${RUNTIME_SA}"
185
248
  fi
186
249
 
250
+ # The transport for a deployment that scales to zero. It is static infrastructure — it
251
+ # knows nothing about jobs — so it belongs here with everything else that is created once,
252
+ # rather than in sync-triggers.sh, which cannot run until an engine is deployed and can
253
+ # be asked what jobs exist.
254
+ #
255
+ # Setting AGENT_TASKS_QUEUE here is also what lets `deploy` create the Cloud Scheduler
256
+ # triggers on the FIRST rollout: it syncs them for a deployment that has opted into
257
+ # external triggers, and these two variables are that opt-in. Without this the two waited
258
+ # on each other and a deployment needed a separate sync-triggers run to break the tie.
259
+ #
260
+ # Only for scale-to-zero. An always-on deployment drives itself with in-process timers, and
261
+ # giving it external triggers as well would fire every cron twice.
262
+ TRIGGER_ENV=""
263
+ if [ "${SCALING}" = "scale-to-zero" ]; then
264
+ QUEUE="${QUEUE_NAME:-${SERVICE}-runs}"
265
+ echo "📥 Cloud Tasks queue ${QUEUE}..."
266
+ ensure_tasks_queue "${QUEUE}"
267
+ AGENT_URL=$(${GC} run services describe "${SERVICE}" --region="${REGION}" \
268
+ --format='value(status.url)' 2>/dev/null || true)
269
+ if [ -z "${AGENT_URL}" ] && [ "${DRY_RUN}" = true ]; then
270
+ AGENT_URL="<the service URL, once it exists>"
271
+ fi
272
+ if [ -n "${AGENT_URL}" ]; then
273
+ TRIGGER_ENV=",AGENT_TASKS_QUEUE=projects/${PROJECT}/locations/${REGION}/queues/${QUEUE},AGENT_PUBLIC_URL=${AGENT_URL}"
274
+ else
275
+ # Not fatal: sync-triggers.sh sets both, and deploy runs it once they are there.
276
+ echo " ⚠️ Could not read the service URL — $(cmd sync-triggers) will set these."
277
+ fi
278
+ fi
279
+
187
280
  echo "⚙️ Applying service settings (instances, memory volume, env)..."
188
281
  VOLUME_FLAGS=()
189
282
  if ! ${GC} run services describe "${SERVICE}" --region="${REGION}" --format=yaml 2>/dev/null | grep -q "name: memory"; then
@@ -194,7 +287,7 @@ fi
194
287
  #
195
288
  # Scale to zero means CPU billed only while a request is open. That is affordable because
196
289
  # 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
290
+ # its own: scripts/sync-triggers.sh gives the deployment its external triggers, and every
198
291
  # run happens inside a request the handler holds open (src/keyed-runs.ts). REQUEST_TIMEOUT
199
292
  # is the ceiling on one such held request; --cpu-boost pays for the cold start each wake-up
200
293
  # costs, which for this service includes cloning the content repo. MAX_INSTANCES=1 keeps
@@ -206,58 +299,52 @@ case "${SCALING}" in
206
299
  always-on) SCALE_FLAGS=(--min-instances=1 --no-cpu-throttling) ;;
207
300
  *) echo "Unknown SCALING=\"${SCALING}\" — use scale-to-zero or always-on." >&2; exit 2 ;;
208
301
  esac
209
- ${GC} run services update "${SERVICE}" --region="${REGION}" \
302
+ run ${GC} run services update "${SERVICE}" --region="${REGION}" \
210
303
  --service-account="${RUNTIME_SA}" \
211
304
  --max-instances="${MAX_INSTANCES}" \
212
305
  "${SCALE_FLAGS[@]}" \
213
306
  --memory="${MEMORY}" --cpu="${CPU}" --timeout="${REQUEST_TIMEOUT}" \
214
307
  --execution-environment=gen2 \
215
308
  ${VOLUME_FLAGS[@]+"${VOLUME_FLAGS[@]}"} \
216
- --update-env-vars="TIMEZONE=${TIMEZONE},AUDIT_BUCKET=${AUDIT_BUCKET}"
309
+ --update-env-vars="TIMEZONE=${TIMEZONE},AUDIT_BUCKET=${AUDIT_BUCKET}${TRIGGER_ENV}"
217
310
 
218
311
  echo ""
219
312
  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)."
313
+ if [ "${DRY_RUN}" = true ]; then
314
+ echo "🔍 Dry run — nothing above was created, changed or granted."
315
+ else
316
+ echo " Infrastructure ready."
317
+ fi
231
318
  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_API_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"
319
+ echo " Created: the Google APIs, the registry the engine image is served from, the login"
320
+ echo " the agent acts as, a bucket for its memory, a write-once bucket for the audit"
321
+ echo " trail, the queue that carries work back to the service, and the service itself —"
322
+ echo " empty, waiting for an engine."
241
323
  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."
324
+ echo " Next, from this directory:"
246
325
  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)."
326
+ echo " $(cmd set-env) GIT_REPO_URL=https://github.com/<owner>/<content-repo>.git"
327
+ echo " $(cmd set-secret) AGENT_API_SECRET --random"
328
+ echo " $(cmd set-secret) CLAUDE_CODE_OAUTH_TOKEN # from: claude setup-token"
329
+ echo " $(cmd set-secret) GITHUB_TOKEN # must be able to clone the repo above"
330
+ echo " $(cmd deploy) # rolls out the engine, then creates its triggers"
331
+ if has_cli; then
332
+ echo ""
333
+ echo " Then give it one ability at a time — mail, analytics, a CRM, a database:"
334
+ echo ""
335
+ echo " ${MEFFECTA_CLI} connect # lists them; connect <name> walks through one"
336
+ echo ""
337
+ echo " ${MEFFECTA_CLI} steps the whole set-up, in order"
338
+ echo " ${MEFFECTA_CLI} doctor what is still missing, once something is running"
339
+ fi
252
340
  if [ "${SETUP_CI_DEPLOY}" = "true" ]; then
253
341
  WIF_PROVIDER="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}"
254
342
  echo ""
255
343
  echo " 6. For a CI deploy from ${GITHUB_REPO} → Settings → Secrets → Actions:"
256
344
  echo " GCP_WORKLOAD_IDENTITY_PROVIDER = ${WIF_PROVIDER}"
257
345
  echo " GCP_DEPLOY_SERVICE_ACCOUNT = ${DEPLOY_SA}"
346
+ echo " That identity may deploy and nothing else — it cannot read your secrets, so"
347
+ echo " the CI step runs \`deploy --no-scheduler\`. Re-sync triggers yourself when a"
348
+ echo " job's cron: changes: $(cmd sync-triggers)"
258
349
  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
350
  echo "============================================================"