Files
JMR-devandClaude Opus 5.5 973c1bbcd3 Keep the ACME contact email in Secret Manager
gitea:acmeEmail sat in Pulumi.prod.yaml, which this public repository
publishes, and was then copied into instance metadata. It now lives in a
gitea-acme-email secret instead, read by the VM when it renders the
Caddyfile.

- scripts/bootstrap.sh creates the secret empty and prints how to set
  it, the same as github-pat: the address is chosen, not generated.
- Pulumi grants the VM secretAccessor on it and nothing more. It is kept
  out of secrets.Names, whose members also get secretVersionAdder and are
  mapped to `gitea generate secret` by vm/bootstrap.sh.
- The gitea:acmeEmail config key and the acme-email metadata entry are
  gone.
- vm/bootstrap.sh renders the whole `email` directive. If the secret is
  unreadable it renders a comment instead and warns: Caddy still issues
  certificates under an account with no contact address, whereas an
  empty `email` would fail to parse and leave nothing serving TLS. Same
  directive-or-comment pattern as CADDY_PUBLISH_PORTS and CADDY_SYSCTL.

README setup gains the secret step, plus two that were missing: ADC
login (Pulumi's GCS backend and provider do not use the gcloud login),
and exporting PULUMI_CONFIG_PASSPHRASE from Secret Manager before
`stack init`. Without the latter, init prompts for a new passphrase and
the stack is encrypted with a key Cloud Build's infra trigger never sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:18 -05:00

268 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# One-time project bootstrap, run from a workstation BEFORE the first
# `pulumi up`.
#
# Everything here exists because Pulumi cannot create it:
#
# * the GCS bucket that holds Pulumi's own state
# * the passphrase that encrypts that state
# * the service account that RUNS Pulumi in Cloud Build
# * Gitea's signing secrets -- INTERNAL_TOKEN must be a valid Gitea-issued
# JWT, so `gitea generate secret` has to produce it, and the values must
# exist before the VM first boots and tries to render app.ini
#
# Idempotent: safe to re-run.
#
# Usage: scripts/bootstrap.sh <project-id> [region] (default region: us-east1)
set -euo pipefail
PROJECT="${1:-}"
REGION="${2:-us-east1}"
if [[ -z "${PROJECT}" ]]; then
echo "usage: $0 <project-id> [region]" >&2
exit 1
fi
STATE_BUCKET="${PROJECT}-pulumi-state"
INFRA_SA="cb-infra"
INFRA_SA_EMAIL="${INFRA_SA}@${PROJECT}.iam.gserviceaccount.com"
GITEA_IMAGE="docker.io/gitea/gitea:latest"
log() { echo "==> $*"; }
command -v gcloud >/dev/null || { echo "gcloud is required" >&2; exit 1; }
# ---------------------------------------------------------------------------
log "enabling APIs"
# ---------------------------------------------------------------------------
gcloud services enable --project="${PROJECT}" \
compute.googleapis.com \
dns.googleapis.com \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com \
secretmanager.googleapis.com \
iap.googleapis.com \
storage.googleapis.com \
logging.googleapis.com \
monitoring.googleapis.com \
cloudscheduler.googleapis.com \
iamcredentials.googleapis.com \
oslogin.googleapis.com
# ---------------------------------------------------------------------------
log "creating Pulumi state bucket gs://${STATE_BUCKET}"
# ---------------------------------------------------------------------------
if ! gcloud storage buckets describe "gs://${STATE_BUCKET}" --project="${PROJECT}" >/dev/null 2>&1; then
gcloud storage buckets create "gs://${STATE_BUCKET}" \
--project="${PROJECT}" \
--location="${REGION}" \
--uniform-bucket-level-access \
--public-access-prevention
fi
# Versioning is the undo button for a corrupted or truncated state file.
gcloud storage buckets update "gs://${STATE_BUCKET}" --versioning --project="${PROJECT}"
# ---------------------------------------------------------------------------
log "creating secrets"
# ---------------------------------------------------------------------------
ensure_secret() {
local name="$1"
if ! gcloud secrets describe "${name}" --project="${PROJECT}" >/dev/null 2>&1; then
gcloud secrets create "${name}" --project="${PROJECT}" \
--replication-policy=automatic --labels=app=gitea
fi
}
has_version() {
gcloud secrets versions list "$1" --project="${PROJECT}" \
--filter='state:ENABLED' --limit=1 --format='value(name)' 2>/dev/null | grep -q .
}
# Pulumi's state encryption passphrase. Generated here so it never lives in a
# shell history or a config file.
ensure_secret pulumi-config-passphrase
if ! has_version pulumi-config-passphrase; then
log "generating Pulumi state passphrase"
openssl rand -base64 48 | tr -d '\n' \
| gcloud secrets versions add pulumi-config-passphrase --project="${PROJECT}" --data-file=-
fi
# The GitHub PAT for the Cloud Build connection. Created empty on purpose --
# a PAT is an interactive artifact and cannot be generated here.
ensure_secret github-pat
if ! has_version github-pat; then
echo " NOTE: secret 'github-pat' has no value yet."
echo " Create a GitHub PAT with repo + read:user scope and run:"
echo " printf %s '<token>' | gcloud secrets versions add github-pat --project=${PROJECT} --data-file=-"
fi
# The ACME contact address Caddy registers with Let's Encrypt. A secret only to
# keep it out of this public repository and out of instance metadata. Created
# empty: the address is yours to choose, not something to generate.
ensure_secret gitea-acme-email
if ! has_version gitea-acme-email; then
echo " NOTE: secret 'gitea-acme-email' has no value yet. Caddy still issues"
echo " certificates without it, but with no contact address. Set it with:"
echo " printf %s 'you@example.com' | gcloud secrets versions add gitea-acme-email --project=${PROJECT} --data-file=-"
fi
# Gitea's signing secrets. These MUST come from `gitea generate secret`:
# INTERNAL_TOKEN is a JWT, and a random string there produces an instance that
# starts and then fails every internal API call in a confusing way.
declare -A GITEA_SECRETS=(
[gitea-secret-key]=SECRET_KEY
[gitea-internal-token]=INTERNAL_TOKEN
[gitea-oauth2-jwt-secret]=JWT_SECRET
[gitea-lfs-jwt-secret]=LFS_JWT_SECRET
)
runner=""
for candidate in podman docker; do
command -v "${candidate}" >/dev/null 2>&1 && { runner="${candidate}"; break; }
done
for name in "${!GITEA_SECRETS[@]}"; do
ensure_secret "${name}"
if has_version "${name}"; then
log "secret ${name} already populated -- leaving it alone"
continue
fi
if [[ -z "${runner}" ]]; then
echo " WARNING: no podman/docker available; cannot generate ${name}." >&2
echo " Install one and re-run, or the VM will skip rendering app.ini." >&2
continue
fi
log "generating ${name}"
# The upstream image is used only as a throwaway generator here; the
# deployed image is our own Debian 13 build.
"${runner}" run --rm "${GITEA_IMAGE}" gitea generate secret "${GITEA_SECRETS[$name]}" \
| tr -d '\n' \
| gcloud secrets versions add "${name}" --project="${PROJECT}" --data-file=-
done
# ---------------------------------------------------------------------------
log "creating the Pulumi runner service account ${INFRA_SA_EMAIL}"
# ---------------------------------------------------------------------------
# This is the chicken-and-egg account: it is the identity that runs `pulumi up`,
# so it cannot be created by `pulumi up`.
if ! gcloud iam service-accounts describe "${INFRA_SA_EMAIL}" --project="${PROJECT}" >/dev/null 2>&1; then
gcloud iam service-accounts create "${INFRA_SA}" \
--project="${PROJECT}" \
--display-name="Cloud Build: infrastructure (runs Pulumi)"
# Service account creation is eventually consistent. Binding a role to an
# account the IAM API cannot see yet fails with
# INVALID_ARGUMENT: Service account ... does not exist
# even though creation just succeeded. Wait for it to appear.
log "waiting for ${INFRA_SA_EMAIL} to propagate"
for _ in $(seq 1 30); do
gcloud iam service-accounts describe "${INFRA_SA_EMAIL}" \
--project="${PROJECT}" >/dev/null 2>&1 && break
sleep 2
done
fi
# `describe` returning the account is necessary but not sufficient -- the IAM
# policy backend can still reject it for a while longer. And each
# add-iam-policy-binding is a read-modify-write of the whole project policy, so
# a run of them in sequence can also collide with itself. Retry on both.
add_project_binding() {
local member="$1" role="$2" out=""
for attempt in $(seq 1 10); do
if out=$(gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="${member}" \
--role="${role}" \
--condition=None \
--quiet 2>&1); then
return 0
fi
case "${out}" in
*"does not exist"*|*oncurrent*)
sleep $(( attempt * 3 ))
;;
*)
echo "${out}" >&2
return 1
;;
esac
done
echo "giving up on ${role}:" >&2
echo "${out}" >&2
return 1
}
# Broad by necessity -- Pulumi manages IAM, compute, DNS, and secrets bindings.
# Deliberately a different identity from cb-image@, which only pushes images.
INFRA_ROLES=(
roles/compute.admin
roles/dns.admin
roles/artifactregistry.admin
roles/secretmanager.admin
roles/iam.serviceAccountAdmin
roles/iam.serviceAccountUser
roles/resourcemanager.projectIamAdmin
roles/serviceusage.serviceUsageAdmin
roles/cloudscheduler.admin
roles/cloudbuild.builds.editor
roles/iap.tunnelResourceAccessor
roles/compute.osAdminLogin
roles/logging.logWriter
)
for role in "${INFRA_ROLES[@]}"; do
log " granting ${role}"
add_project_binding "serviceAccount:${INFRA_SA_EMAIL}" "${role}"
done
# Pulumi's state lives in the bucket, so the runner needs write access to it --
# scoped to that bucket rather than project-wide storage admin. Same
# propagation caveat applies.
bucket_bound=false
for attempt in $(seq 1 10); do
if out=$(gcloud storage buckets add-iam-policy-binding "gs://${STATE_BUCKET}" \
--project="${PROJECT}" \
--member="serviceAccount:${INFRA_SA_EMAIL}" \
--role=roles/storage.admin 2>&1); then
bucket_bound=true
break
fi
case "${out}" in
*"does not exist"*|*oncurrent*) sleep $(( attempt * 3 )) ;;
*) echo "${out}" >&2; exit 1 ;;
esac
done
# Without this the loop would fall through after exhausting its retries and the
# script would print its success summary having granted nothing.
if [[ "${bucket_bound}" != true ]]; then
echo "failed to grant storage.admin on gs://${STATE_BUCKET} after 10 attempts:" >&2
echo "${out}" >&2
exit 1
fi
cat <<SUMMARY
Bootstrap complete.
Pulumi backend : gs://${STATE_BUCKET}
Pulumi runner : ${INFRA_SA_EMAIL}
Next:
1. Install the Cloud Build GitHub App on your repository and note the
installation id, then populate the github-pat secret (see note above).
2. Confirm DNS delegation: dig NS <your-domain>
3. cd infra
pulumi login gs://${STATE_BUCKET}
pulumi stack init prod
pulumi config set gcp:project ${PROJECT}
pulumi config set gitea:infraBuildServiceAccount ${INFRA_SA_EMAIL}
# ...plus domain, dnsZone, githubOwner, githubAppInstallationId
pulumi up
4. make build # or, spelled out:
gcloud builds submit --config cloudbuild/image.yaml --project ${PROJECT} \\
--region ${REGION} \\
--service-account projects/${PROJECT}/serviceAccounts/cb-image@${PROJECT}.iam.gserviceaccount.com
SUMMARY