@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,17 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "keep-recent-versions",
|
|
4
|
+
"action": { "type": "Keep" },
|
|
5
|
+
"mostRecentVersions": { "keepCount": 5 }
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"name": "delete-untagged",
|
|
9
|
+
"action": { "type": "Delete" },
|
|
10
|
+
"condition": { "tagState": "UNTAGGED", "olderThan": "1d" }
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "delete-superseded",
|
|
14
|
+
"action": { "type": "Delete" },
|
|
15
|
+
"condition": { "tagState": "ANY", "olderThan": "30d" }
|
|
16
|
+
}
|
|
17
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Step 1 of IMPLEMENTATION.md: create the GCP project this deployment will live in.
|
|
5
|
+
#
|
|
6
|
+
# Give the agent a project of its own, separate from any product project it will read:
|
|
7
|
+
# its service account gets project-wide secret access, and the mail tokens stored here
|
|
8
|
+
# must not be readable by another system's machinery.
|
|
9
|
+
#
|
|
10
|
+
# Asks for a display name, suggests a project ID from it, and creates the project. The
|
|
11
|
+
# ID is what every later command needs, and it is not always the one you asked for — if
|
|
12
|
+
# it is taken you pick another here, so you learn it now rather than at the first failure.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# scripts/create-project.sh
|
|
16
|
+
# scripts/create-project.sh --name "Acme Agent" --id acme-agent
|
|
17
|
+
# ORGANIZATION=123456789 scripts/create-project.sh # or FOLDER=...
|
|
18
|
+
|
|
19
|
+
NAME=""
|
|
20
|
+
PROJECT_ID=""
|
|
21
|
+
|
|
22
|
+
while [ $# -gt 0 ]; do
|
|
23
|
+
case "$1" in
|
|
24
|
+
--name) NAME="${2:?--name needs a value}"; shift 2 ;;
|
|
25
|
+
--id) PROJECT_ID="${2:?--id needs a value}"; shift 2 ;;
|
|
26
|
+
-h|--help) sed -n '4,18p' "$0"; exit 0 ;;
|
|
27
|
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
28
|
+
esac
|
|
29
|
+
done
|
|
30
|
+
|
|
31
|
+
command -v gcloud >/dev/null || { echo "gcloud is required." >&2; exit 1; }
|
|
32
|
+
|
|
33
|
+
if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' 2>/dev/null | grep -q .; then
|
|
34
|
+
echo "No active gcloud account. Run: gcloud auth login" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Google's rules: 6-30 chars, lowercase letters, digits and hyphens, starts with a
|
|
39
|
+
# letter, does not end with one. Checked here so a typo fails instantly, not after a
|
|
40
|
+
# round trip.
|
|
41
|
+
valid_id() {
|
|
42
|
+
case "$1" in
|
|
43
|
+
[a-z]*[a-z0-9]) [ ${#1} -ge 6 ] && [ ${#1} -le 30 ] && [ -z "$(printf '%s' "$1" | tr -d 'a-z0-9-')" ] ;;
|
|
44
|
+
*) return 1 ;;
|
|
45
|
+
esac
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
suggest_id() {
|
|
49
|
+
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//; s/-*$//' | cut -c1-30
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if [ -z "${NAME}" ]; then
|
|
53
|
+
if [ ! -t 0 ]; then
|
|
54
|
+
echo "No display name given and nothing to prompt with. Use --name." >&2
|
|
55
|
+
exit 2
|
|
56
|
+
fi
|
|
57
|
+
printf 'Display name for the project (e.g. "Acme Agent"): '
|
|
58
|
+
IFS= read -r NAME
|
|
59
|
+
fi
|
|
60
|
+
[ -n "${NAME}" ] || { echo "A display name is required." >&2; exit 2; }
|
|
61
|
+
|
|
62
|
+
if [ -z "${PROJECT_ID}" ]; then
|
|
63
|
+
SUGGESTED=$(suggest_id "${NAME}")
|
|
64
|
+
if [ -t 0 ]; then
|
|
65
|
+
printf 'Project ID [%s]: ' "${SUGGESTED}"
|
|
66
|
+
IFS= read -r PROJECT_ID
|
|
67
|
+
fi
|
|
68
|
+
PROJECT_ID="${PROJECT_ID:-${SUGGESTED}}"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if ! valid_id "${PROJECT_ID}"; then
|
|
72
|
+
echo "" >&2
|
|
73
|
+
echo "'${PROJECT_ID}' is not a valid project ID." >&2
|
|
74
|
+
echo "Rules: 6-30 characters, lowercase letters, digits and hyphens, starting with a" >&2
|
|
75
|
+
echo "letter and not ending with a hyphen." >&2
|
|
76
|
+
exit 2
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
echo ""
|
|
80
|
+
echo "▶ Creating project"
|
|
81
|
+
echo " id: ${PROJECT_ID}"
|
|
82
|
+
echo " name: ${NAME}"
|
|
83
|
+
[ -n "${ORGANIZATION:-}" ] && echo " org: ${ORGANIZATION}"
|
|
84
|
+
[ -n "${FOLDER:-}" ] && echo " folder: ${FOLDER}"
|
|
85
|
+
echo ""
|
|
86
|
+
|
|
87
|
+
ARGS=(projects create "${PROJECT_ID}" --name "${NAME}")
|
|
88
|
+
[ -n "${ORGANIZATION:-}" ] && ARGS+=(--organization "${ORGANIZATION}")
|
|
89
|
+
[ -n "${FOLDER:-}" ] && ARGS+=(--folder "${FOLDER}")
|
|
90
|
+
|
|
91
|
+
if ! gcloud "${ARGS[@]}"; then
|
|
92
|
+
echo "" >&2
|
|
93
|
+
echo "Project creation failed. The usual cause is that the ID is already taken —" >&2
|
|
94
|
+
echo "globally, across all of Google Cloud, not just your organization. Re-run with" >&2
|
|
95
|
+
echo "a different one, e.g. --id ${PROJECT_ID}-$(od -An -N2 -tu2 </dev/urandom | tr -d ' ')" >&2
|
|
96
|
+
exit 1
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
echo ""
|
|
100
|
+
echo "✔ Created ${PROJECT_ID}"
|
|
101
|
+
echo ""
|
|
102
|
+
echo "Next: link a billing account (nothing else works without it) —"
|
|
103
|
+
echo " $(dirname "$0")/link-billing.sh --project ${PROJECT_ID}"
|
|
104
|
+
echo ""
|
|
105
|
+
echo "And in step 3, this goes in your content repo's deployment.env:"
|
|
106
|
+
echo " PROJECT=${PROJECT_ID}"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Roll a published engine image out to one deployment's Cloud Run service.
|
|
5
|
+
#
|
|
6
|
+
# The engine's CI publishes images to GHCR and stops there: choosing when to move to a
|
|
7
|
+
# new engine version is the deployment's call, not the engine repo's. This script is
|
|
8
|
+
# that call, and it is the same script for Marcus's instance and for a client's.
|
|
9
|
+
#
|
|
10
|
+
# Cloud Run can only pull from Artifact Registry, never from GHCR directly, so an image
|
|
11
|
+
# has to reach the deployment's own AR first. Two ways:
|
|
12
|
+
#
|
|
13
|
+
# 1. Mirror (default here): pull from GHCR, retag, push to AR. Needs docker locally,
|
|
14
|
+
# plus `gcloud auth configure-docker <region>-docker.pkg.dev` once, and — for a
|
|
15
|
+
# private GHCR package — `docker login ghcr.io` with a PAT that has read:packages.
|
|
16
|
+
# 2. AR remote repository: a one-time pull-through proxy of GHCR in the deployment's
|
|
17
|
+
# project, after which no local docker is involved. Then point --image straight at
|
|
18
|
+
# the proxied path and skip the mirror:
|
|
19
|
+
# gcloud artifacts repositories create ghcr-remote --repository-format=docker \
|
|
20
|
+
# --mode=remote-repository --location=<region> \
|
|
21
|
+
# --remote-docker-repo=https://ghcr.io # + upstream creds for a private package
|
|
22
|
+
#
|
|
23
|
+
# What it writes, and what it deliberately does not.
|
|
24
|
+
#
|
|
25
|
+
# The image and the **runtime shape** — scaling, CPU, memory, request timeout — are
|
|
26
|
+
# asserted on every rollout, from deployment.env (SCALING, CPU, MEMORY, MAX_INSTANCES,
|
|
27
|
+
# REQUEST_TIMEOUT, with the defaults in lib/deployment.sh). That is the point: a flag
|
|
28
|
+
# changed by hand in the console would otherwise survive every deploy silently, and this
|
|
29
|
+
# shape is not decoration — `scale-to-zero` only works because the service is driven from
|
|
30
|
+
# outside, so a service that quietly drifted back to always-on would run every cron twice.
|
|
31
|
+
#
|
|
32
|
+
# Env vars, secrets, volumes and the service account are NOT touched. Those are supplied
|
|
33
|
+
# by set-env.sh, set-secret.sh and setup-infrastructure.sh, and a wrong guess about a
|
|
34
|
+
# bucket name or a service account breaks a deployment far worse than drift does.
|
|
35
|
+
#
|
|
36
|
+
# A deployment driven by Cloud Scheduler also gets its triggers re-synced afterwards
|
|
37
|
+
# (scripts/setup-scheduler.sh): a deploy restarts the service, which is when new or renamed
|
|
38
|
+
# `cron:` registrations from the content repo take effect, so it is also when the Scheduler
|
|
39
|
+
# jobs mirroring them have to be brought up to date. An always-on deployment drives itself
|
|
40
|
+
# and is left alone.
|
|
41
|
+
#
|
|
42
|
+
# Usage:
|
|
43
|
+
# scripts/deploy.sh # the newest published engine (--tag latest)
|
|
44
|
+
# scripts/deploy.sh --tag sha-556ca72 # a specific build, e.g. to roll back
|
|
45
|
+
# scripts/deploy.sh --image europe-west1-docker.pkg.dev/PROJECT/REPO/agent:sha-556ca72
|
|
46
|
+
# scripts/deploy.sh --dry-run # print the target, image and shape; change nothing
|
|
47
|
+
# scripts/deploy.sh --no-scheduler # roll out without re-syncing the triggers
|
|
48
|
+
#
|
|
49
|
+
# Which deployment: ./deployment.env, or --config <file>. See deployment.env.example.
|
|
50
|
+
|
|
51
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
52
|
+
|
|
53
|
+
TAG="latest"
|
|
54
|
+
IMAGE=""
|
|
55
|
+
DRY_RUN=false
|
|
56
|
+
SYNC_SCHEDULER=true
|
|
57
|
+
|
|
58
|
+
while [ $# -gt 0 ]; do
|
|
59
|
+
case "$1" in
|
|
60
|
+
--config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
|
|
61
|
+
--tag) TAG="${2:?--tag needs a value}"; shift 2 ;;
|
|
62
|
+
--image) IMAGE="${2:?--image needs a value}"; shift 2 ;;
|
|
63
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
64
|
+
--no-scheduler) SYNC_SCHEDULER=false; shift ;;
|
|
65
|
+
-h|--help) sed -n '3,49p' "$0"; exit 0 ;;
|
|
66
|
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
67
|
+
esac
|
|
68
|
+
done
|
|
69
|
+
|
|
70
|
+
require_gcloud
|
|
71
|
+
resolve_deployment
|
|
72
|
+
announce_target "Deploying ${SERVICE}"
|
|
73
|
+
|
|
74
|
+
# --tag mirrors GHCR → this deployment's Artifact Registry; --image is taken as-is
|
|
75
|
+
# (already in AR, or reachable through an AR remote repository).
|
|
76
|
+
if [ -z "${IMAGE}" ]; then
|
|
77
|
+
SOURCE="${GHCR_IMAGE}:${TAG}"
|
|
78
|
+
IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${ARTIFACT_REPO}/${SERVICE}:${TAG}"
|
|
79
|
+
command -v docker >/dev/null || {
|
|
80
|
+
echo "docker is required to mirror ${SOURCE} into Artifact Registry." >&2
|
|
81
|
+
echo "Either install it, or set up an AR remote repository and pass --image." >&2
|
|
82
|
+
exit 1
|
|
83
|
+
}
|
|
84
|
+
echo "📥 Mirroring ${SOURCE} → ${IMAGE}"
|
|
85
|
+
if [ "${DRY_RUN}" = true ]; then
|
|
86
|
+
echo " (dry run: would pull, retag and push)"
|
|
87
|
+
else
|
|
88
|
+
if ! docker pull "${SOURCE}"; then
|
|
89
|
+
cat >&2 <<EOF
|
|
90
|
+
|
|
91
|
+
Could not pull ${SOURCE}.
|
|
92
|
+
|
|
93
|
+
The published engine image is public, so this needs no login — check the tag exists
|
|
94
|
+
and that the host can reach ghcr.io:
|
|
95
|
+
|
|
96
|
+
curl -sS "https://ghcr.io/token?scope=repository:meffecta/agent:pull&service=ghcr.io"
|
|
97
|
+
|
|
98
|
+
If you are pointing GHCR_IMAGE at a private package of your own instead, that one does
|
|
99
|
+
need a token with the read:packages scope, which the gh CLI does not request by default:
|
|
100
|
+
|
|
101
|
+
gh auth refresh -h github.com -s read:packages
|
|
102
|
+
gh auth token | docker login ghcr.io -u <your-github-username> --password-stdin
|
|
103
|
+
EOF
|
|
104
|
+
exit 1
|
|
105
|
+
fi
|
|
106
|
+
docker tag "${SOURCE}" "${IMAGE}"
|
|
107
|
+
if ! docker push "${IMAGE}"; then
|
|
108
|
+
cat >&2 <<EOF
|
|
109
|
+
|
|
110
|
+
Could not push to ${IMAGE}.
|
|
111
|
+
|
|
112
|
+
Artifact Registry needs docker configured for this region once:
|
|
113
|
+
|
|
114
|
+
gcloud auth configure-docker ${REGION}-docker.pkg.dev
|
|
115
|
+
EOF
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
fi
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# scale-to-zero is the default and what setup-scheduler.sh's triggers assume: no CPU
|
|
122
|
+
# except while a request is open, which is only correct because nothing in the service
|
|
123
|
+
# fires on its own. always-on is the other coherent pair, for a deployment that drives
|
|
124
|
+
# itself with in-process timers instead.
|
|
125
|
+
case "${SCALING}" in
|
|
126
|
+
scale-to-zero) SCALE_FLAGS=(--min-instances=0 --cpu-throttling --cpu-boost) ;;
|
|
127
|
+
always-on) SCALE_FLAGS=(--min-instances=1 --no-cpu-throttling) ;;
|
|
128
|
+
*)
|
|
129
|
+
echo "Unknown SCALING=\"${SCALING}\" — use scale-to-zero or always-on." >&2
|
|
130
|
+
exit 2
|
|
131
|
+
;;
|
|
132
|
+
esac
|
|
133
|
+
|
|
134
|
+
echo "🧩 Rolling out ${IMAGE}"
|
|
135
|
+
echo " shape: ${SCALING}, cpu ${CPU}, memory ${MEMORY}, max ${MAX_INSTANCES} instance(s), timeout ${REQUEST_TIMEOUT}s"
|
|
136
|
+
if [ "${DRY_RUN}" = true ]; then
|
|
137
|
+
echo " (dry run: nothing was changed)"
|
|
138
|
+
exit 0
|
|
139
|
+
fi
|
|
140
|
+
gcloud run deploy "${SERVICE}" \
|
|
141
|
+
--project "${PROJECT}" \
|
|
142
|
+
--region "${REGION}" \
|
|
143
|
+
--platform managed \
|
|
144
|
+
--image "${IMAGE}" \
|
|
145
|
+
--cpu "${CPU}" \
|
|
146
|
+
--memory "${MEMORY}" \
|
|
147
|
+
--max-instances "${MAX_INSTANCES}" \
|
|
148
|
+
--timeout "${REQUEST_TIMEOUT}" \
|
|
149
|
+
--execution-environment gen2 \
|
|
150
|
+
"${SCALE_FLAGS[@]}"
|
|
151
|
+
|
|
152
|
+
# Only for a deployment that has opted into external triggers — AGENT_TASKS_QUEUE on the
|
|
153
|
+
# service is what says so. Creating Scheduler jobs for an always-on one would mean every
|
|
154
|
+
# cron fires twice, once from outside and once from the in-process timer.
|
|
155
|
+
if [ "${SYNC_SCHEDULER}" = true ] &&
|
|
156
|
+
gcloud run services describe "${SERVICE}" --project "${PROJECT}" --region "${REGION}" \
|
|
157
|
+
--format json 2>/dev/null | grep -q AGENT_TASKS_QUEUE; then
|
|
158
|
+
echo ""
|
|
159
|
+
"$(dirname "${BASH_SOURCE[0]}")/setup-scheduler.sh" ${CONFIG_FILE:+--config "${CONFIG_FILE}"}
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
URL=$(gcloud run services describe "${SERVICE}" \
|
|
163
|
+
--project "${PROJECT}" --region "${REGION}" \
|
|
164
|
+
--format 'value(status.url)' 2>/dev/null || true)
|
|
165
|
+
if [ -n "${URL}" ]; then
|
|
166
|
+
echo "🌍 ${SERVICE} → ${URL}"
|
|
167
|
+
echo " Liveness: curl -fsS ${URL}/health"
|
|
168
|
+
echo " Ask it: ${URL}/ask (any username, AGENT_WEBHOOK_SECRET as the password)"
|
|
169
|
+
fi
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Shared configuration for the operator scripts. Sourced, never run.
|
|
2
|
+
#
|
|
3
|
+
# One deployment = one config file, and that file belongs in the deployment's CONTENT
|
|
4
|
+
# repo, committed: which GCP project and service a set of jobs runs as is a fact about
|
|
5
|
+
# that deployment, not about the engine. None of it is secret (secrets live in Secret
|
|
6
|
+
# Manager), so it is checked in, and a fresh clone of the content repo is enough to
|
|
7
|
+
# operate the deployment.
|
|
8
|
+
#
|
|
9
|
+
# So the usual shape is: cd to the content repo, run the engine's scripts from there.
|
|
10
|
+
#
|
|
11
|
+
# cd ~/work/acme-agent-home && ~/work/agent/scripts/deploy.sh
|
|
12
|
+
#
|
|
13
|
+
# Resolution order, first hit wins per value:
|
|
14
|
+
# 1. a variable already set in the environment (one-off override)
|
|
15
|
+
# 2. the config file: --config <file>, else $DEPLOYMENT_CONFIG, else ./deployment.env
|
|
16
|
+
# in the current directory
|
|
17
|
+
#
|
|
18
|
+
# Nothing is guessed beyond that: every script prints the project and service it is about
|
|
19
|
+
# to touch and refuses to run without them, so being in the wrong directory is an error
|
|
20
|
+
# rather than a command against the wrong deployment.
|
|
21
|
+
#
|
|
22
|
+
# Config file format is plain KEY=value lines, # comments allowed. See
|
|
23
|
+
# deployment.env.example.
|
|
24
|
+
|
|
25
|
+
# Consume --config <file> from a script's arguments before it parses its own.
|
|
26
|
+
# Usage: eval "$(extract_config_flag "$@")" — sets CONFIG_FILE and rewrites "$@".
|
|
27
|
+
CONFIG_FILE=""
|
|
28
|
+
|
|
29
|
+
load_deployment_config() {
|
|
30
|
+
local file="${CONFIG_FILE:-${DEPLOYMENT_CONFIG:-}}"
|
|
31
|
+
if [ -z "${file}" ]; then
|
|
32
|
+
file="${PWD}/deployment.env" # i.e. the content repo you are standing in
|
|
33
|
+
fi
|
|
34
|
+
if [ ! -f "${file}" ]; then
|
|
35
|
+
if [ -n "${CONFIG_FILE}" ] || [ -n "${DEPLOYMENT_CONFIG:-}" ]; then
|
|
36
|
+
echo "Config file not found: ${file}" >&2
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
return 0 # no config file is fine as long as the environment supplies the values
|
|
40
|
+
fi
|
|
41
|
+
DEPLOYMENT_CONFIG_USED="${file}"
|
|
42
|
+
local key value
|
|
43
|
+
while IFS='=' read -r key value || [ -n "${key}" ]; do
|
|
44
|
+
key="${key%%#*}" # drop trailing comment on the key side
|
|
45
|
+
key="$(printf '%s' "${key}" | tr -d '[:space:]')"
|
|
46
|
+
[ -z "${key}" ] && continue
|
|
47
|
+
value="${value%$'\r'}" # tolerate CRLF
|
|
48
|
+
value="${value#"${value%%[![:space:]]*}"}" # trim leading space
|
|
49
|
+
value="${value%"${value##*[![:space:]]}"}" # trim trailing space
|
|
50
|
+
value="${value%\"}"; value="${value#\"}" # strip surrounding quotes
|
|
51
|
+
value="${value%\'}"; value="${value#\'}"
|
|
52
|
+
# An environment variable already set wins — that is the one-off override.
|
|
53
|
+
if [ -z "$(eval "printf '%s' \"\${${key}:-}\"" 2>/dev/null)" ]; then
|
|
54
|
+
eval "${key}=\$value"
|
|
55
|
+
fi
|
|
56
|
+
done <"${file}"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Fill in what the config file did not, then insist on the two that must never be guessed.
|
|
60
|
+
resolve_deployment() {
|
|
61
|
+
load_deployment_config
|
|
62
|
+
REGION="${REGION:-europe-west1}"
|
|
63
|
+
ARTIFACT_REPO="${ARTIFACT_REPO:-${SERVICE:-agent}-images}"
|
|
64
|
+
GHCR_IMAGE="${GHCR_IMAGE:-ghcr.io/meffecta/agent}"
|
|
65
|
+
TIMEZONE="${TIMEZONE:-Europe/Stockholm}"
|
|
66
|
+
|
|
67
|
+
# The runtime shape, asserted by deploy.sh on every rollout so the deployed service
|
|
68
|
+
# matches this file rather than whatever was last done by hand in the console. Defaults
|
|
69
|
+
# are the shape setup-infrastructure.sh provisions; override any of them per deployment.
|
|
70
|
+
SCALING="${SCALING:-scale-to-zero}"
|
|
71
|
+
CPU="${CPU:-2}"
|
|
72
|
+
MEMORY="${MEMORY:-4Gi}"
|
|
73
|
+
MAX_INSTANCES="${MAX_INSTANCES:-1}"
|
|
74
|
+
REQUEST_TIMEOUT="${REQUEST_TIMEOUT:-3600}"
|
|
75
|
+
|
|
76
|
+
local missing=""
|
|
77
|
+
[ -z "${PROJECT:-}" ] && missing="${missing} PROJECT"
|
|
78
|
+
[ -z "${SERVICE:-}" ] && missing="${missing} SERVICE"
|
|
79
|
+
if [ -n "${missing}" ]; then
|
|
80
|
+
cat >&2 <<EOF
|
|
81
|
+
Missing required setting(s):${missing}
|
|
82
|
+
|
|
83
|
+
No deployment.env found in $(pwd).
|
|
84
|
+
|
|
85
|
+
That file lives in the deployment's content repo (committed — none of it is
|
|
86
|
+
secret), so the usual fix is to run this from there:
|
|
87
|
+
|
|
88
|
+
cd /path/to/your-content-repo && ${0}
|
|
89
|
+
|
|
90
|
+
Creating one: copy scripts/deployment.env.example beside your jobs/. Or point
|
|
91
|
+
at a file explicitly with --config /path/to/deployment.env.
|
|
92
|
+
EOF
|
|
93
|
+
exit 2
|
|
94
|
+
fi
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Every script says what it is about to touch, before it touches it.
|
|
98
|
+
announce_target() {
|
|
99
|
+
local action="$1"
|
|
100
|
+
echo "▶ ${action}"
|
|
101
|
+
echo " project: ${PROJECT}"
|
|
102
|
+
echo " service: ${SERVICE} (${REGION})"
|
|
103
|
+
[ -n "${DEPLOYMENT_CONFIG_USED:-}" ] && echo " config: ${DEPLOYMENT_CONFIG_USED}"
|
|
104
|
+
echo ""
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
require_gcloud() {
|
|
108
|
+
command -v gcloud >/dev/null || { echo "gcloud is required." >&2; exit 1; }
|
|
109
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Step 2 of IMPLEMENTATION.md: link a billing account to the project.
|
|
5
|
+
#
|
|
6
|
+
# Nothing else works without this — provisioning starts by enabling APIs, which fails on
|
|
7
|
+
# an unbilled project, and the failure does not mention billing.
|
|
8
|
+
#
|
|
9
|
+
# Lists the billing accounts you can use, lets you pick one, links it, and verifies the
|
|
10
|
+
# result. Closed accounts are shown but cannot be selected: linking one silently leaves
|
|
11
|
+
# the project unbilled.
|
|
12
|
+
#
|
|
13
|
+
# The project comes from --project, else PROJECT in ./deployment.env or the environment,
|
|
14
|
+
# else a prompt.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# scripts/link-billing.sh
|
|
18
|
+
# scripts/link-billing.sh --project acme-agent-506513
|
|
19
|
+
# scripts/link-billing.sh --project acme-agent-506513 --billing-account 0X0X0X-...
|
|
20
|
+
|
|
21
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
22
|
+
|
|
23
|
+
BILLING_ACCOUNT=""
|
|
24
|
+
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
|
|
28
|
+
--project) PROJECT="${2:?--project needs a value}"; shift 2 ;;
|
|
29
|
+
--billing-account) BILLING_ACCOUNT="${2:?--billing-account needs a value}"; shift 2 ;;
|
|
30
|
+
-h|--help) sed -n '4,20p' "$0"; exit 0 ;;
|
|
31
|
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
|
|
35
|
+
require_gcloud
|
|
36
|
+
# Soft load: at this point in the set-up a content repo may not exist yet, so a missing
|
|
37
|
+
# deployment.env is normal — unlike the later scripts, this one only needs PROJECT.
|
|
38
|
+
load_deployment_config
|
|
39
|
+
|
|
40
|
+
if [ -z "${PROJECT:-}" ]; then
|
|
41
|
+
if [ ! -t 0 ]; then
|
|
42
|
+
echo "No project given. Use --project, or run from a content repo with deployment.env." >&2
|
|
43
|
+
exit 2
|
|
44
|
+
fi
|
|
45
|
+
printf 'Project ID: '
|
|
46
|
+
IFS= read -r PROJECT
|
|
47
|
+
fi
|
|
48
|
+
[ -n "${PROJECT}" ] || { echo "A project ID is required." >&2; exit 2; }
|
|
49
|
+
|
|
50
|
+
gcloud projects describe "${PROJECT}" --format='value(projectId)' >/dev/null 2>&1 || {
|
|
51
|
+
echo "Cannot see project '${PROJECT}' — check the ID with: gcloud projects list" >&2
|
|
52
|
+
exit 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
CURRENT=$(gcloud billing projects describe "${PROJECT}" --format='value(billingEnabled)' 2>/dev/null || echo "")
|
|
56
|
+
if [ "${CURRENT}" = "True" ]; then
|
|
57
|
+
ACCOUNT=$(gcloud billing projects describe "${PROJECT}" --format='value(billingAccountName)' 2>/dev/null || echo "")
|
|
58
|
+
echo "✔ ${PROJECT} already has billing enabled (${ACCOUNT#billingAccounts/})."
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
if [ -z "${BILLING_ACCOUNT}" ]; then
|
|
63
|
+
echo "Billing accounts you can use:"
|
|
64
|
+
echo ""
|
|
65
|
+
IDS=()
|
|
66
|
+
INDEX=0
|
|
67
|
+
# displayName can contain spaces, so split on tabs rather than whitespace.
|
|
68
|
+
while IFS=$'\t' read -r id display open; do
|
|
69
|
+
[ -n "${id}" ] || continue
|
|
70
|
+
if [ "${open}" = "True" ]; then
|
|
71
|
+
INDEX=$((INDEX + 1))
|
|
72
|
+
IDS+=("${id}")
|
|
73
|
+
printf ' %d) %-24s %s\n' "${INDEX}" "${id}" "${display}"
|
|
74
|
+
else
|
|
75
|
+
printf ' %-24s %s (closed — cannot be linked)\n' "${id}" "${display}"
|
|
76
|
+
fi
|
|
77
|
+
done < <(gcloud billing accounts list --format='value[separator=" "](name.basename(),displayName,open)' 2>/dev/null)
|
|
78
|
+
|
|
79
|
+
if [ ${#IDS[@]} -eq 0 ]; then
|
|
80
|
+
echo " (none open)" >&2
|
|
81
|
+
echo "" >&2
|
|
82
|
+
echo "No open billing account is available to this login. Create one at" >&2
|
|
83
|
+
echo "https://console.cloud.google.com/billing, or ask whoever administers billing" >&2
|
|
84
|
+
echo "to grant you Billing Account User on theirs." >&2
|
|
85
|
+
exit 1
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
echo ""
|
|
89
|
+
if [ ${#IDS[@]} -eq 1 ]; then
|
|
90
|
+
BILLING_ACCOUNT="${IDS[0]}"
|
|
91
|
+
echo "Only one open account — using ${BILLING_ACCOUNT}."
|
|
92
|
+
else
|
|
93
|
+
if [ ! -t 0 ]; then
|
|
94
|
+
echo "Several accounts available; pass --billing-account <id>." >&2
|
|
95
|
+
exit 2
|
|
96
|
+
fi
|
|
97
|
+
printf 'Select [1-%d]: ' "${#IDS[@]}"
|
|
98
|
+
IFS= read -r CHOICE
|
|
99
|
+
case "${CHOICE}" in
|
|
100
|
+
''|*[!0-9]*) echo "Not a number: '${CHOICE}'" >&2; exit 2 ;;
|
|
101
|
+
esac
|
|
102
|
+
if [ "${CHOICE}" -lt 1 ] || [ "${CHOICE}" -gt ${#IDS[@]} ]; then
|
|
103
|
+
echo "Out of range: ${CHOICE}" >&2
|
|
104
|
+
exit 2
|
|
105
|
+
fi
|
|
106
|
+
BILLING_ACCOUNT="${IDS[$((CHOICE - 1))]}"
|
|
107
|
+
fi
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
echo ""
|
|
111
|
+
echo "▶ Linking ${BILLING_ACCOUNT} to ${PROJECT}"
|
|
112
|
+
LINK_ERR=$(mktemp)
|
|
113
|
+
trap 'rm -f "${LINK_ERR}"' EXIT
|
|
114
|
+
if ! gcloud billing projects link "${PROJECT}" --billing-account="${BILLING_ACCOUNT}" >/dev/null 2>"${LINK_ERR}"; then
|
|
115
|
+
cat "${LINK_ERR}" >&2
|
|
116
|
+
if grep -qi 'quota' "${LINK_ERR}"; then
|
|
117
|
+
cat >&2 <<EOF
|
|
118
|
+
|
|
119
|
+
That is the billing account's project quota, not a problem with this project: a
|
|
120
|
+
billing account may only fund so many projects, and yours is full. Either free a
|
|
121
|
+
slot —
|
|
122
|
+
|
|
123
|
+
gcloud projects list # find one you no longer need
|
|
124
|
+
gcloud billing projects unlink <PROJECT> # or delete the project outright
|
|
125
|
+
|
|
126
|
+
— or request an increase at
|
|
127
|
+
https://support.google.com/code/contact/billing_quota_increase
|
|
128
|
+
EOF
|
|
129
|
+
fi
|
|
130
|
+
exit 1
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
# Linking can report success while leaving the project unbilled (a closed account), so
|
|
134
|
+
# confirm the state rather than the command's exit code.
|
|
135
|
+
if [ "$(gcloud billing projects describe "${PROJECT}" --format='value(billingEnabled)' 2>/dev/null)" = "True" ]; then
|
|
136
|
+
echo "✔ billingEnabled: true"
|
|
137
|
+
echo ""
|
|
138
|
+
echo "Next: create the content repo (step 3), then provision from inside it —"
|
|
139
|
+
echo " $(dirname "$0")/setup-infrastructure.sh"
|
|
140
|
+
else
|
|
141
|
+
echo "✖ Linked, but billingEnabled is still false — check the account is open and funded." >&2
|
|
142
|
+
exit 1
|
|
143
|
+
fi
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-time, local: mint the OAuth refresh token the agent uses for the private
|
|
3
|
+
// Gmail account. Opens a browser consent flow via the loopback redirect and
|
|
4
|
+
// prints the refresh token — store it as the GMAIL_REFRESH_TOKEN secret.
|
|
5
|
+
//
|
|
6
|
+
// Prerequisites (once, in the agent's GCP project):
|
|
7
|
+
// 1. Enable the Gmail API and Google Calendar API.
|
|
8
|
+
// 2. OAuth consent screen: External, then PUBLISH TO "In production" —
|
|
9
|
+
// an app left in "Testing" expires refresh tokens after 7 days.
|
|
10
|
+
// 3. Create an OAuth client of type "Desktop app"; note client id + secret.
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// node scripts/mint-gmail-token.mjs --client-id <id> --client-secret <secret>
|
|
14
|
+
// [--account <NAME>] [--readonly]
|
|
15
|
+
//
|
|
16
|
+
// Sign in as WHICHEVER account the token is for when the browser opens — run once
|
|
17
|
+
// per account (private Gmail → GMAIL_REFRESH_TOKEN; additional accounts →
|
|
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
|
|
20
|
+
// company mailbox: read the mail, never send from that identity).
|
|
21
|
+
// Gmail scopes are "restricted", so an unverified app
|
|
22
|
+
// shows a warning — Advanced → continue is fine for your own account. A Workspace
|
|
23
|
+
// account you don't control only works if that tenant's admin policy allows
|
|
24
|
+
// third-party OAuth apps; a refusal at consent means their admin has to allowlist
|
|
25
|
+
// this client id.
|
|
26
|
+
|
|
27
|
+
import { createServer } from "node:http";
|
|
28
|
+
import { parseArgs } from "node:util";
|
|
29
|
+
|
|
30
|
+
const { values: args } = parseArgs({
|
|
31
|
+
options: {
|
|
32
|
+
"client-id": { type: "string" },
|
|
33
|
+
"client-secret": { type: "string" },
|
|
34
|
+
account: { type: "string" },
|
|
35
|
+
readonly: { type: "boolean", default: false },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const accountName = args.account?.toUpperCase();
|
|
40
|
+
if (accountName && !/^[A-Z0-9]+$/.test(accountName)) {
|
|
41
|
+
console.error("--account must be a single alphanumeric word (it becomes GMAIL_<NAME>_REFRESH_TOKEN)");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const tokenVar = accountName ? `GMAIL_${accountName}_REFRESH_TOKEN` : "GMAIL_REFRESH_TOKEN";
|
|
45
|
+
|
|
46
|
+
const clientId = args["client-id"] ?? process.env.GMAIL_CLIENT_ID;
|
|
47
|
+
const clientSecret = args["client-secret"] ?? process.env.GMAIL_CLIENT_SECRET;
|
|
48
|
+
if (!clientId || !clientSecret) {
|
|
49
|
+
console.error("usage: mint-gmail-token.mjs --client-id <id> --client-secret <secret>");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const READ_SCOPES = [
|
|
54
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
55
|
+
"https://www.googleapis.com/auth/calendar.readonly",
|
|
56
|
+
"https://www.googleapis.com/auth/drive.readonly",
|
|
57
|
+
];
|
|
58
|
+
const SCOPES = args.readonly
|
|
59
|
+
? READ_SCOPES
|
|
60
|
+
: [
|
|
61
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
62
|
+
"https://www.googleapis.com/auth/gmail.send",
|
|
63
|
+
"https://www.googleapis.com/auth/gmail.compose",
|
|
64
|
+
"https://www.googleapis.com/auth/calendar",
|
|
65
|
+
"https://www.googleapis.com/auth/drive",
|
|
66
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
67
|
+
"https://www.googleapis.com/auth/documents",
|
|
68
|
+
"https://www.googleapis.com/auth/presentations",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const server = createServer();
|
|
72
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
73
|
+
const redirectUri = `http://127.0.0.1:${server.address().port}`;
|
|
74
|
+
|
|
75
|
+
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
76
|
+
authUrl.search = new URLSearchParams({
|
|
77
|
+
client_id: clientId,
|
|
78
|
+
redirect_uri: redirectUri,
|
|
79
|
+
response_type: "code",
|
|
80
|
+
scope: SCOPES.join(" "),
|
|
81
|
+
access_type: "offline",
|
|
82
|
+
// Force a fresh consent so Google returns a refresh token even if one was granted before.
|
|
83
|
+
prompt: "consent",
|
|
84
|
+
}).toString();
|
|
85
|
+
|
|
86
|
+
console.error(
|
|
87
|
+
`Open this URL in a browser and sign in as ${accountName ? `the ${accountName} account` : "the private account"}` +
|
|
88
|
+
` (scopes: ${args.readonly ? "read-only" : "full"}):\n`,
|
|
89
|
+
);
|
|
90
|
+
console.error(authUrl.toString());
|
|
91
|
+
console.error("\nWaiting for the redirect...");
|
|
92
|
+
|
|
93
|
+
const code = await new Promise((resolve, reject) => {
|
|
94
|
+
server.on("request", (req, res) => {
|
|
95
|
+
const url = new URL(req.url, redirectUri);
|
|
96
|
+
const err = url.searchParams.get("error");
|
|
97
|
+
const c = url.searchParams.get("code");
|
|
98
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
99
|
+
res.end(err ? `<h3>Failed: ${err}</h3>` : "<h3>Done — you can close this tab.</h3>");
|
|
100
|
+
if (err) reject(new Error(err));
|
|
101
|
+
else if (c) resolve(c);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
server.close();
|
|
105
|
+
|
|
106
|
+
const res = await fetch("https://oauth2.googleapis.com/token", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
109
|
+
body: new URLSearchParams({
|
|
110
|
+
client_id: clientId,
|
|
111
|
+
client_secret: clientSecret,
|
|
112
|
+
code,
|
|
113
|
+
grant_type: "authorization_code",
|
|
114
|
+
redirect_uri: redirectUri,
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
const json = await res.json();
|
|
118
|
+
if (!res.ok || !json.refresh_token) {
|
|
119
|
+
console.error(`token exchange failed (${res.status}): ${JSON.stringify(json)}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Refresh token to stdout (pipe straight into gcloud), everything else to stderr.
|
|
124
|
+
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`);
|
|
126
|
+
console.log(json.refresh_token);
|