@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,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-time, local: mint the OAuth refresh token the agent uses to read a Microsoft
|
|
3
|
+
// 365 mailbox through Microsoft Graph. Opens a browser consent flow via a loopback
|
|
4
|
+
// redirect and prints the refresh token — store it as MSGRAPH_<NAME>_REFRESH_TOKEN.
|
|
5
|
+
//
|
|
6
|
+
// Delegated, never app-only. An app-only Mail.Read grant reaches EVERY mailbox in
|
|
7
|
+
// the tenant and has to be fenced back in with an Exchange ApplicationAccessPolicy;
|
|
8
|
+
// a delegated token can only ever reach the mailbox that signs in below. For a
|
|
9
|
+
// mailbox in someone else's company that difference is the whole point.
|
|
10
|
+
//
|
|
11
|
+
// Prerequisites (once, in the target tenant — you must be able to register an app):
|
|
12
|
+
// 1. Entra admin center → App registrations → New registration.
|
|
13
|
+
// - Supported account types: "Accounts in this organizational directory only".
|
|
14
|
+
// - Platform "Web", Redirect URI EXACTLY http://localhost:3014/
|
|
15
|
+
// Entra matches redirect URIs exactly — there is no random-loopback-port
|
|
16
|
+
// exemption as with Google, which is why the port here is pinned.
|
|
17
|
+
// 2. Certificates & secrets → New client secret; copy the VALUE, not the id.
|
|
18
|
+
// The secret is what makes this a confidential client, whose refresh tokens
|
|
19
|
+
// live until revoked. Without one, Entra caps refresh tokens at 90 days and
|
|
20
|
+
// this breaks every quarter. Client secrets themselves expire (24 months max)
|
|
21
|
+
// — note the date somewhere you will see it.
|
|
22
|
+
// 3. API permissions → Microsoft Graph → Delegated → Mail.Read AND Calendars.Read.
|
|
23
|
+
// Grant admin consent if the tenant requires it. (--no-calendar mints without
|
|
24
|
+
// Calendars.Read; the calendar is normally the more useful half, so it is on
|
|
25
|
+
// by default and dropping it later means a fresh consent round.)
|
|
26
|
+
// 4. Note the Application (client) ID and the Directory (tenant) ID.
|
|
27
|
+
//
|
|
28
|
+
// Usage:
|
|
29
|
+
// node scripts/mint-graph-token.mjs --account MOXSEA --tenant <tenant-id> \
|
|
30
|
+
// --client-id <id> --client-secret <secret> [--no-calendar]
|
|
31
|
+
//
|
|
32
|
+
// Sign in as the mailbox the token is for when the browser opens — the everyday
|
|
33
|
+
// account, NOT a tenant admin account. An Admin-* identity usually carries no
|
|
34
|
+
// Exchange licence, so the consent succeeds and every later mail call returns 404
|
|
35
|
+
// "mailbox is either inactive, soft-deleted, or is hosted on-premise". If the
|
|
36
|
+
// browser is already signed in as the admin, use "Use another account" or a private
|
|
37
|
+
// window. Scopes are read-only by construction: this mints no Mail.Send and no write
|
|
38
|
+
// scope of any kind, so a token minted here cannot send, reply, or delete.
|
|
39
|
+
|
|
40
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
41
|
+
import { createServer } from "node:http";
|
|
42
|
+
import { parseArgs } from "node:util";
|
|
43
|
+
|
|
44
|
+
const { values: args } = parseArgs({
|
|
45
|
+
options: {
|
|
46
|
+
account: { type: "string" },
|
|
47
|
+
tenant: { type: "string" },
|
|
48
|
+
"client-id": { type: "string" },
|
|
49
|
+
"client-secret": { type: "string" },
|
|
50
|
+
"no-calendar": { type: "boolean", default: false },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Entra's redirect URI must match the registration exactly, so this is fixed.
|
|
55
|
+
const PORT = 3014;
|
|
56
|
+
const REDIRECT_URI = `http://localhost:${PORT}/`;
|
|
57
|
+
|
|
58
|
+
const accountName = args.account?.toUpperCase();
|
|
59
|
+
if (!accountName || !/^[A-Z0-9]+$/.test(accountName)) {
|
|
60
|
+
console.error(
|
|
61
|
+
"--account is required and must be a single alphanumeric word (it becomes MSGRAPH_<NAME>_REFRESH_TOKEN)",
|
|
62
|
+
);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const tokenVar = `MSGRAPH_${accountName}_REFRESH_TOKEN`;
|
|
66
|
+
|
|
67
|
+
const tenant = args.tenant ?? process.env.MSGRAPH_TENANT_ID;
|
|
68
|
+
const clientId = args["client-id"] ?? process.env.MSGRAPH_CLIENT_ID;
|
|
69
|
+
const clientSecret = args["client-secret"] ?? process.env.MSGRAPH_CLIENT_SECRET;
|
|
70
|
+
if (!tenant || !clientId || !clientSecret) {
|
|
71
|
+
console.error("usage: mint-graph-token.mjs --account <NAME> --tenant <id> --client-id <id> --client-secret <secret>");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SCOPES = [
|
|
76
|
+
"offline_access",
|
|
77
|
+
"https://graph.microsoft.com/Mail.Read",
|
|
78
|
+
...(args["no-calendar"] ? [] : ["https://graph.microsoft.com/Calendars.Read"]),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// PKCE: required for public clients, harmless here, and it keeps the authorization
|
|
82
|
+
// code useless to anything that intercepts the loopback redirect.
|
|
83
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
84
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
85
|
+
|
|
86
|
+
const server = createServer();
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
server.once("error", (err) =>
|
|
89
|
+
reject(
|
|
90
|
+
err.code === "EADDRINUSE"
|
|
91
|
+
? new Error(`port ${PORT} is busy — free it; the redirect URI is registered against that exact port`)
|
|
92
|
+
: err,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
server.listen(PORT, "127.0.0.1", resolve);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const authUrl = new URL(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`);
|
|
99
|
+
authUrl.search = new URLSearchParams({
|
|
100
|
+
client_id: clientId,
|
|
101
|
+
response_type: "code",
|
|
102
|
+
redirect_uri: REDIRECT_URI,
|
|
103
|
+
response_mode: "query",
|
|
104
|
+
scope: SCOPES.join(" "),
|
|
105
|
+
code_challenge: challenge,
|
|
106
|
+
code_challenge_method: "S256",
|
|
107
|
+
// Force account choice: signing in as the wrong identity is the easy mistake here.
|
|
108
|
+
prompt: "select_account",
|
|
109
|
+
}).toString();
|
|
110
|
+
|
|
111
|
+
console.error(`Open this URL and sign in as the ${accountName} mailbox (read-only scopes: ${SCOPES.join(", ")}).`);
|
|
112
|
+
console.error("Use the everyday account, not a tenant admin account — admin identities have no mailbox.\n");
|
|
113
|
+
console.error(authUrl.toString());
|
|
114
|
+
console.error("\nWaiting for the redirect...");
|
|
115
|
+
|
|
116
|
+
const code = await new Promise((resolve, reject) => {
|
|
117
|
+
server.on("request", (req, res) => {
|
|
118
|
+
const url = new URL(req.url, REDIRECT_URI);
|
|
119
|
+
const err = url.searchParams.get("error");
|
|
120
|
+
const description = url.searchParams.get("error_description");
|
|
121
|
+
const c = url.searchParams.get("code");
|
|
122
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
123
|
+
res.end(err ? `<h3>Failed: ${err}</h3><p>${description ?? ""}</p>` : "<h3>Done — you can close this tab.</h3>");
|
|
124
|
+
if (err) reject(new Error(`${err}: ${description ?? ""}`));
|
|
125
|
+
else if (c) resolve(c);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
server.close();
|
|
129
|
+
|
|
130
|
+
const res = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
133
|
+
body: new URLSearchParams({
|
|
134
|
+
client_id: clientId,
|
|
135
|
+
client_secret: clientSecret,
|
|
136
|
+
code,
|
|
137
|
+
grant_type: "authorization_code",
|
|
138
|
+
redirect_uri: REDIRECT_URI,
|
|
139
|
+
code_verifier: verifier,
|
|
140
|
+
scope: SCOPES.join(" "),
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
const json = await res.json();
|
|
144
|
+
if (!res.ok || !json.refresh_token) {
|
|
145
|
+
console.error(`token exchange failed (${res.status}): ${JSON.stringify(json.error_description ?? json)}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Refresh token to stdout (pipe straight into gcloud), everything else to stderr.
|
|
150
|
+
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`);
|
|
152
|
+
console.log(json.refresh_token);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Stop a deployment's Artifact Registry filling up with engine images it will never use
|
|
5
|
+
# again.
|
|
6
|
+
#
|
|
7
|
+
# The images there are a **cache**, not an archive: deploy.sh mirrors them from
|
|
8
|
+
# ghcr.io/meffecta/agent because Cloud Run cannot pull from GHCR directly. GHCR keeps the
|
|
9
|
+
# real copies, so anything deleted here comes back on the next `deploy.sh --tag <that one>`.
|
|
10
|
+
# That is what makes an aggressive policy correct for this repo and wrong for a repo whose
|
|
11
|
+
# images have nowhere else to come from.
|
|
12
|
+
#
|
|
13
|
+
# The policy (artifact-cleanup-policy.json, beside this script):
|
|
14
|
+
# keep-recent-versions the newest 5 per image, always
|
|
15
|
+
# delete-untagged superseded pushes — the previous :latest, mostly — after a day
|
|
16
|
+
# delete-superseded anything at all, once it is 30 days old
|
|
17
|
+
#
|
|
18
|
+
# Keep rules win over delete rules in Artifact Registry, which is the safety property this
|
|
19
|
+
# leans on: **the image the live revision pins must never be deleted.** A Cloud Run revision
|
|
20
|
+
# pins a digest, and under scale-to-zero every cold start pulls it — so deleting the running
|
|
21
|
+
# image does not merely block the next deploy, it takes the service down at the next wake-up.
|
|
22
|
+
# The newest 5 covers the live one with four deploys of slack.
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
# scripts/set-artifact-cleanup.sh # apply the policy; deletions are real
|
|
26
|
+
# scripts/set-artifact-cleanup.sh --observe # apply it in Artifact Registry's own
|
|
27
|
+
# # dry-run mode: it logs what it would
|
|
28
|
+
# # delete and deletes nothing
|
|
29
|
+
# scripts/set-artifact-cleanup.sh --dry-run # change nothing at all, just show
|
|
30
|
+
# scripts/set-artifact-cleanup.sh --off # remove the policy
|
|
31
|
+
#
|
|
32
|
+
# Which deployment: ./deployment.env, or --config <file>. See deployment.env.example.
|
|
33
|
+
|
|
34
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
35
|
+
|
|
36
|
+
POLICY_FILE="$(dirname "${BASH_SOURCE[0]}")/artifact-cleanup-policy.json"
|
|
37
|
+
DRY_RUN=false
|
|
38
|
+
OBSERVE=false
|
|
39
|
+
REMOVE=false
|
|
40
|
+
|
|
41
|
+
while [ $# -gt 0 ]; do
|
|
42
|
+
case "$1" in
|
|
43
|
+
--config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
|
|
44
|
+
--policy) POLICY_FILE="${2:?--policy needs a file}"; shift 2 ;;
|
|
45
|
+
--observe) OBSERVE=true; shift ;;
|
|
46
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
47
|
+
--off) REMOVE=true; shift ;;
|
|
48
|
+
-h|--help) sed -n '3,33p' "$0"; exit 0 ;;
|
|
49
|
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
50
|
+
esac
|
|
51
|
+
done
|
|
52
|
+
|
|
53
|
+
require_gcloud
|
|
54
|
+
resolve_deployment
|
|
55
|
+
announce_target "Setting the image cleanup policy on ${ARTIFACT_REPO}"
|
|
56
|
+
|
|
57
|
+
[ -f "${POLICY_FILE}" ] || { echo "Policy file not found: ${POLICY_FILE}" >&2; exit 1; }
|
|
58
|
+
|
|
59
|
+
GC="gcloud --project=${PROJECT}"
|
|
60
|
+
[ -n "${ACCOUNT:-}" ] && GC="${GC} --account=${ACCOUNT}"
|
|
61
|
+
|
|
62
|
+
# gcloud prints the size only in its default layout, not as a --format key.
|
|
63
|
+
SIZE_BEFORE=$(${GC} artifacts repositories describe "${ARTIFACT_REPO}" --location="${REGION}" 2>/dev/null |
|
|
64
|
+
sed -n 's/^Repository Size: //p' || echo "")
|
|
65
|
+
[ -n "${SIZE_BEFORE}" ] && echo " current size: ${SIZE_BEFORE}"
|
|
66
|
+
|
|
67
|
+
if [ "${REMOVE}" = true ]; then
|
|
68
|
+
echo "🧹 Removing the cleanup policy — nothing will be deleted automatically any more."
|
|
69
|
+
if [ "${DRY_RUN}" = true ]; then
|
|
70
|
+
echo " (dry run: nothing was changed)"
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
${GC} artifacts repositories delete-cleanup-policies "${ARTIFACT_REPO}" --location="${REGION}" \
|
|
74
|
+
--policynames=keep-recent-versions,delete-untagged,delete-superseded
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
echo "🧹 Policy:"
|
|
79
|
+
sed 's/^/ /' "${POLICY_FILE}"
|
|
80
|
+
|
|
81
|
+
FLAGS=(--policy="${POLICY_FILE}")
|
|
82
|
+
if [ "${OBSERVE}" = true ]; then
|
|
83
|
+
# Artifact Registry evaluates the policy and logs what it would remove, without removing
|
|
84
|
+
# it. The log filter is printed below — give it a few hours to run.
|
|
85
|
+
FLAGS+=(--dry-run)
|
|
86
|
+
echo " mode: observe (Artifact Registry logs deletions instead of making them)"
|
|
87
|
+
else
|
|
88
|
+
FLAGS+=(--no-dry-run)
|
|
89
|
+
echo " mode: enforcing (deletions are real, and the images come back from GHCR if needed)"
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
if [ "${DRY_RUN}" = true ]; then
|
|
93
|
+
echo " (dry run: nothing was changed)"
|
|
94
|
+
exit 0
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
${GC} artifacts repositories set-cleanup-policies "${ARTIFACT_REPO}" --location="${REGION}" "${FLAGS[@]}"
|
|
98
|
+
|
|
99
|
+
echo ""
|
|
100
|
+
echo "✅ Applied. Artifact Registry evaluates the policy on its own schedule, so the size"
|
|
101
|
+
echo " drops over the following hours rather than immediately."
|
|
102
|
+
if [ "${OBSERVE}" = true ]; then
|
|
103
|
+
echo ""
|
|
104
|
+
echo " Watch what it would delete:"
|
|
105
|
+
echo " gcloud logging read 'resource.type=\"artifactregistry.googleapis.com/Repository\"" \
|
|
106
|
+
"AND jsonPayload.policyName!=\"\"' --project ${PROJECT} --limit 50"
|
|
107
|
+
echo " Then re-run without --observe to let it act."
|
|
108
|
+
fi
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Set plain (non-secret) environment variables on the deployment's Cloud Run service.
|
|
5
|
+
# Several at once become one revision instead of one each.
|
|
6
|
+
#
|
|
7
|
+
# Anything sensitive belongs in scripts/set-secret.sh instead — values passed here are
|
|
8
|
+
# visible in your shell history and in the service's configuration.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# scripts/set-env.sh GIT_REPO_URL=https://github.com/acme/agent-home.git
|
|
12
|
+
# scripts/set-env.sh GMAIL_ADDRESS=a@b.com TIMEZONE=Europe/Stockholm
|
|
13
|
+
# scripts/set-env.sh --unset SOME_OLD_VAR
|
|
14
|
+
#
|
|
15
|
+
# --config <file> use another deployment's config (default: ./deployment.env)
|
|
16
|
+
|
|
17
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
18
|
+
|
|
19
|
+
PAIRS=()
|
|
20
|
+
UNSET_KEYS=()
|
|
21
|
+
DRY_RUN=false
|
|
22
|
+
|
|
23
|
+
while [ $# -gt 0 ]; do
|
|
24
|
+
case "$1" in
|
|
25
|
+
--config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
|
|
26
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
27
|
+
--unset) UNSET_KEYS+=("${2:?--unset needs a name}"); shift 2 ;;
|
|
28
|
+
-h|--help) sed -n '4,16p' "$0"; exit 0 ;;
|
|
29
|
+
-*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
30
|
+
*)
|
|
31
|
+
case "$1" in
|
|
32
|
+
*=*) PAIRS+=("$1") ;;
|
|
33
|
+
*) echo "Expected NAME=VALUE, got '$1'" >&2; exit 2 ;;
|
|
34
|
+
esac
|
|
35
|
+
shift ;;
|
|
36
|
+
esac
|
|
37
|
+
done
|
|
38
|
+
|
|
39
|
+
if [ ${#PAIRS[@]} -eq 0 ] && [ ${#UNSET_KEYS[@]} -eq 0 ]; then
|
|
40
|
+
echo "Nothing to do: pass NAME=VALUE pairs, or --unset NAME." >&2
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
require_gcloud
|
|
45
|
+
resolve_deployment
|
|
46
|
+
announce_target "Updating environment on ${SERVICE}"
|
|
47
|
+
|
|
48
|
+
ARGS=()
|
|
49
|
+
if [ ${#PAIRS[@]} -gt 0 ]; then
|
|
50
|
+
# gcloud splits --update-env-vars on commas unless a custom delimiter is declared as
|
|
51
|
+
# ^delim^. Values here legitimately contain commas (a job list) and @ (an address), so
|
|
52
|
+
# pick a delimiter none of them uses rather than assuming.
|
|
53
|
+
DELIM=""
|
|
54
|
+
for candidate in ',' '|' '#' '~'; do
|
|
55
|
+
if ! printf '%s\n' "${PAIRS[@]}" | grep -qF "${candidate}"; then
|
|
56
|
+
DELIM="${candidate}"
|
|
57
|
+
break
|
|
58
|
+
fi
|
|
59
|
+
done
|
|
60
|
+
[ -n "${DELIM}" ] || { echo "Values use every candidate delimiter; set these one at a time." >&2; exit 1; }
|
|
61
|
+
|
|
62
|
+
JOINED=""
|
|
63
|
+
for pair in "${PAIRS[@]}"; do
|
|
64
|
+
[ -n "${JOINED}" ] && JOINED="${JOINED}${DELIM}"
|
|
65
|
+
JOINED="${JOINED}${pair}"
|
|
66
|
+
echo " set ${pair%%=*}"
|
|
67
|
+
done
|
|
68
|
+
if [ "${DELIM}" = ',' ]; then
|
|
69
|
+
ARGS+=(--update-env-vars "${JOINED}")
|
|
70
|
+
else
|
|
71
|
+
ARGS+=(--update-env-vars "^${DELIM}^${JOINED}")
|
|
72
|
+
fi
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
if [ ${#UNSET_KEYS[@]} -gt 0 ]; then
|
|
76
|
+
REMOVE=""
|
|
77
|
+
for key in "${UNSET_KEYS[@]}"; do
|
|
78
|
+
[ -n "${REMOVE}" ] && REMOVE="${REMOVE},"
|
|
79
|
+
REMOVE="${REMOVE}${key}"
|
|
80
|
+
echo " unset ${key}"
|
|
81
|
+
done
|
|
82
|
+
ARGS+=(--remove-env-vars "${REMOVE}")
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
echo ""
|
|
86
|
+
if [ "${DRY_RUN}" = true ]; then
|
|
87
|
+
echo "(dry run: nothing was changed)"
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
gcloud run services update "${SERVICE}" --project "${PROJECT}" --region "${REGION}" \
|
|
91
|
+
"${ARGS[@]}" >/dev/null
|
|
92
|
+
echo "✔ Updated (new revision rolling out)."
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Put a secret in Secret Manager and bind it to the deployment's Cloud Run service.
|
|
5
|
+
# Creates the secret on first use, adds a new version afterwards, and wires the service
|
|
6
|
+
# to :latest — so re-running it rotates a value in one step.
|
|
7
|
+
#
|
|
8
|
+
# The value never appears in your shell history, in the process list, or on screen.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# scripts/set-secret.sh AGENT_WEBHOOK_SECRET --random # generate one, 32 random bytes
|
|
12
|
+
# scripts/set-secret.sh GITHUB_TOKEN # prompt (hidden), or read stdin
|
|
13
|
+
# scripts/set-secret.sh GMAIL_REFRESH_TOKEN --from-file token.txt
|
|
14
|
+
# cat token.txt | scripts/set-secret.sh GMAIL_REFRESH_TOKEN
|
|
15
|
+
#
|
|
16
|
+
# --config <file> use another deployment's config (default: ./deployment.env)
|
|
17
|
+
# --no-bind store the version only, leave the service untouched
|
|
18
|
+
|
|
19
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
20
|
+
|
|
21
|
+
NAME=""
|
|
22
|
+
RANDOM_VALUE=false
|
|
23
|
+
FROM_FILE=""
|
|
24
|
+
BIND=true
|
|
25
|
+
|
|
26
|
+
while [ $# -gt 0 ]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
|
|
29
|
+
--random) RANDOM_VALUE=true; shift ;;
|
|
30
|
+
--from-file) FROM_FILE="${2:?--from-file needs a path}"; shift 2 ;;
|
|
31
|
+
--no-bind) BIND=false; shift ;;
|
|
32
|
+
-h|--help) sed -n '4,20p' "$0"; exit 0 ;;
|
|
33
|
+
-*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
34
|
+
*) NAME="$1"; shift ;;
|
|
35
|
+
esac
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
[ -n "${NAME}" ] || { echo "Which secret? e.g. scripts/set-secret.sh GITHUB_TOKEN" >&2; exit 2; }
|
|
39
|
+
case "${NAME}" in
|
|
40
|
+
[A-Z]*[A-Z0-9]|[A-Z]) ;;
|
|
41
|
+
*) echo "Secret names are UPPER_SNAKE_CASE: got '${NAME}'" >&2; exit 2 ;;
|
|
42
|
+
esac
|
|
43
|
+
|
|
44
|
+
require_gcloud
|
|
45
|
+
resolve_deployment
|
|
46
|
+
announce_target "Setting secret ${NAME}"
|
|
47
|
+
|
|
48
|
+
# --- Collect the value without ever putting it on the command line ---
|
|
49
|
+
VALUE_FILE=$(mktemp)
|
|
50
|
+
trap 'rm -f "${VALUE_FILE}"' EXIT
|
|
51
|
+
|
|
52
|
+
if [ "${RANDOM_VALUE}" = true ]; then
|
|
53
|
+
command -v openssl >/dev/null || { echo "openssl is required for --random." >&2; exit 1; }
|
|
54
|
+
openssl rand -hex 32 | tr -d '\n' >"${VALUE_FILE}"
|
|
55
|
+
echo "Generated a new 32-byte random value."
|
|
56
|
+
elif [ -n "${FROM_FILE}" ]; then
|
|
57
|
+
[ -f "${FROM_FILE}" ] || { echo "No such file: ${FROM_FILE}" >&2; exit 1; }
|
|
58
|
+
tr -d '\n' <"${FROM_FILE}" >"${VALUE_FILE}"
|
|
59
|
+
elif [ ! -t 0 ]; then
|
|
60
|
+
tr -d '\n' >"${VALUE_FILE}" # piped in
|
|
61
|
+
else
|
|
62
|
+
printf 'Value for %s (input hidden): ' "${NAME}" >&2
|
|
63
|
+
IFS= read -rs TYPED
|
|
64
|
+
echo "" >&2
|
|
65
|
+
printf '%s' "${TYPED}" >"${VALUE_FILE}"
|
|
66
|
+
unset TYPED
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
[ -s "${VALUE_FILE}" ] || { echo "Refusing to store an empty value for ${NAME}." >&2; exit 1; }
|
|
70
|
+
|
|
71
|
+
if gcloud secrets describe "${NAME}" --project "${PROJECT}" >/dev/null 2>&1; then
|
|
72
|
+
gcloud secrets versions add "${NAME}" --project "${PROJECT}" --data-file="${VALUE_FILE}" >/dev/null
|
|
73
|
+
echo "✔ Added a new version of ${NAME}."
|
|
74
|
+
else
|
|
75
|
+
gcloud secrets create "${NAME}" --project "${PROJECT}" --data-file="${VALUE_FILE}" \
|
|
76
|
+
--replication-policy=automatic >/dev/null
|
|
77
|
+
echo "✔ Created secret ${NAME}."
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
if [ "${BIND}" = true ]; then
|
|
81
|
+
gcloud run services update "${SERVICE}" --project "${PROJECT}" --region "${REGION}" \
|
|
82
|
+
--update-secrets "${NAME}=${NAME}:latest" >/dev/null
|
|
83
|
+
echo "✔ Bound ${NAME} on ${SERVICE} (new revision rolling out)."
|
|
84
|
+
else
|
|
85
|
+
echo "• Service untouched (--no-bind)."
|
|
86
|
+
fi
|