#!/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 [region] (default region: us-east1) set -euo pipefail PROJECT="${1:-}" REGION="${2:-us-east1}" if [[ -z "${PROJECT}" ]]; then echo "usage: $0 [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 '' | 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 < 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