Files
2026-05-26 08:28:42 -05:00

217 lines
8.5 KiB
YAML

# Cloud Build pipeline: full deploy.
#
# Steps:
# 1. pulumi up — provision/update Hetzner infra + GCP DNS.
# 2. build + Trivy scan + push the custom Caddy image to Artifact Registry.
# 3. open the Hetzner Cloud Firewall to this Cloud Build run's outbound IP
# (Pulumi-managed; declarative).
# 4. ansible-playbook against the host.
# 5. close the firewall hole — runs even if Ansible failed (always-do step).
#
# All secrets come from Secret Manager via `availableSecrets`. The Cloud Build
# SA needs:
# - roles/secretmanager.secretAccessor on the secrets below
# - roles/dns.admin on the GCP DNS managed zone
# - roles/storage.objectAdmin on $_PULUMI_STATE_BUCKET
# - roles/artifactregistry.writer on the AR repo created by Pulumi
#
# Operator-side prereqs (one-time):
# - GCS bucket for Pulumi state, $_PULUMI_STATE_BUCKET
# - All `vaultwarden-*` secrets created (see secrets/README.md)
# - Cloud Build trigger pointed at this file with the substitutions filled
#
# The AR repo itself is owned by Pulumi (`gcp.artifactregistry.Repository`)
# with `allUsers` granted `roles/artifactregistry.reader` so the rootless
# Hetzner host pulls without auth. The full image path is exported by
# Pulumi as `imageRepo`; pulumi-up writes it to /workspace/.image-repo and
# every downstream step reads from there.
#
# First-time bootstrap is still done locally — Cloud Build can't take over
# until WG exists on the host and the Pulumi stack exists. See
# runbook/README.md §2.
substitutions:
_PULUMI_STACK: production
_PULUMI_STATE_BUCKET: gs://REPLACE-with-pulumi-state-bucket
availableSecrets:
secretManager:
- versionName: projects/$PROJECT_ID/secrets/vaultwarden-hcloud-token/versions/latest
env: HCLOUD_TOKEN
- versionName: projects/$PROJECT_ID/secrets/vaultwarden-ssh-private-key/versions/latest
env: SSH_PRIVATE_KEY
steps:
# 1. Pulumi up — converge infra against repo state. Writes the AR image
# path to /workspace/.image-repo for downstream steps.
- id: pulumi-up
name: pulumi/pulumi-go:latest
dir: pulumi
entrypoint: bash
secretEnv: [HCLOUD_TOKEN]
args:
- -ceux
- |
pulumi login "${_PULUMI_STATE_BUCKET}"
pulumi stack select "${_PULUMI_STACK}"
pulumi up --yes --skip-preview
pulumi stack output imageRepo > /workspace/.image-repo
# 2. Build the custom Caddy image.
- id: build-caddy
name: gcr.io/cloud-builders/docker
entrypoint: bash
args:
- -ceux
- |
IMAGE_REPO="$(cat /workspace/.image-repo)"
set -a; . caddy/versions.env; set +a
docker build \
--build-arg GO_BUILDER_IMAGE="$$GO_BUILDER_IMAGE" \
--build-arg RUNTIME_IMAGE="$$RUNTIME_IMAGE" \
--build-arg XCADDY_VERSION="$$XCADDY_VERSION" \
--build-arg CADDY_VERSION="$$CADDY_VERSION" \
--build-arg CORAZA_CADDY_MODULE="$$CORAZA_CADDY_MODULE" \
--build-arg CORAZA_CADDY_VERSION="$$CORAZA_CADDY_VERSION" \
--build-arg GCD_MODULE="$$GCD_MODULE" \
--build-arg GCD_VERSION="$$GCD_VERSION" \
-t "$$IMAGE_REPO:${SHORT_SHA}" \
-t "$$IMAGE_REPO:latest" \
-f caddy/Containerfile \
caddy
# 3a. Resolve the Trivy version. versions.env pins a minor; the helper
# queries Docker Hub for the highest published patch in that minor.
# Falls back to TRIVY_FALLBACK on any network/parse failure so a
# transient Docker Hub blip doesn't fail the build.
- id: resolve-trivy-version
name: docker.io/library/golang:1-trixie
entrypoint: bash
args:
- -ceu
- |
set -a; . caddy/versions.env; set +a
cd caddy/cmd/trivy-version
go run . > /workspace/.trivy-version
echo "Resolved Trivy: $(cat /workspace/.trivy-version)"
# 3b. Trivy scan against the just-built image. Fails fast on HIGH/CRITICAL.
- id: trivy-scan
name: gcr.io/cloud-builders/docker
waitFor: [build-caddy, resolve-trivy-version]
entrypoint: bash
args:
- -ceu
- |
IMAGE_REPO="$(cat /workspace/.image-repo)"
TAG="$(cat /workspace/.trivy-version)"
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:$$TAG \
image \
--severity=HIGH,CRITICAL \
--exit-code=1 \
--ignore-unfixed \
--no-progress \
"$$IMAGE_REPO:${SHORT_SHA}"
# 4. Push to Artifact Registry. The Cloud Build SA's ADC handles auth —
# `gcloud auth configure-docker` installs a credential helper that
# pipes docker's auth challenges through gcloud, so no token secret is
# needed.
- id: push-caddy
name: gcr.io/cloud-builders/docker
entrypoint: bash
args:
- -ceux
- |
IMAGE_REPO="$(cat /workspace/.image-repo)"
AR_HOST="$$(echo "$$IMAGE_REPO" | cut -d/ -f1)"
gcloud auth configure-docker "$$AR_HOST" --quiet
docker push "$$IMAGE_REPO:${SHORT_SHA}"
docker push "$$IMAGE_REPO:latest"
# 5. Ansible deploy. Opens the firewall to this run's outbound IP via
# Pulumi config flip, runs the playbook, then re-runs pulumi to close.
# Both flips are in one bash step with `trap` so the close always runs.
- id: ansible-deploy
# cloud-sdk:slim ships gcloud + python, which Ansible's gcloud-pipe
# secret lookups need. Plain ubuntu:24.04 lacked gcloud entirely.
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
entrypoint: bash
secretEnv: [SSH_PRIVATE_KEY, HCLOUD_TOKEN]
args:
- -ceu
- |
# No `set -x` here — this step touches SSH_PRIVATE_KEY and
# HCLOUD_TOKEN. Trace would dump the expanded `echo "$SSH_PRIVATE_KEY"
# > /root/.ssh/id_ed25519` into Cloud Build's persistent log.
# Cleanup trap: close the firewall hole no matter how this step exits.
close_hole() {
set +e
pushd /workspace/pulumi >/dev/null
pulumi login "${_PULUMI_STATE_BUCKET}"
pulumi stack select "${_PULUMI_STACK}"
pulumi config set vaultwarden:bootstrapSshCidr ""
pulumi up --yes --skip-preview
popd >/dev/null
}
trap close_hole EXIT
# Install ansible + pulumi on top of cloud-sdk:slim.
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ansible-core openssh-client jq
curl -fsSL https://get.pulumi.com | sh
export PATH="$$HOME/.pulumi/bin:$$PATH"
ansible-galaxy collection install -r /workspace/ansible/requirements.yml -p /workspace/ansible/.collections
# Discover this build's outbound IP. Used twice: the Pulumi-managed
# Cloud Firewall rule and the host nftables extra-ssh allow.
MY_IP="$(curl -fsS https://ifconfig.me)/32"
# Open the Cloud Firewall to MY_IP.
pushd /workspace/pulumi >/dev/null
pulumi login "${_PULUMI_STATE_BUCKET}"
pulumi stack select "${_PULUMI_STACK}"
pulumi config set vaultwarden:bootstrapSshCidr "$$MY_IP"
pulumi up --yes --skip-preview
HOST_IP="$$(pulumi stack output ipv4)"
popd >/dev/null
# SSH key for Ansible.
mkdir -p /root/.ssh
printf '%s' "$$SSH_PRIVATE_KEY" > /root/.ssh/id_ed25519
chmod 600 /root/.ssh/id_ed25519
ssh-keyscan -H "$$HOST_IP" >> /root/.ssh/known_hosts
# The operator commits inventory/<stack>.yml (with real wg_peers
# pubkeys, which are not secret) leaving ansible_host as the
# REPLACE_WITH_IPV4_FROM_PULUMI placeholder. Cloud Build only
# substitutes the IP.
cd /workspace/ansible
test -f inventory/${_PULUMI_STACK}.yml || {
echo "inventory/${_PULUMI_STACK}.yml is missing; commit it from the example." >&2
exit 1
}
sed -i "s|REPLACE_WITH_IPV4_FROM_PULUMI|$$HOST_IP|" inventory/${_PULUMI_STACK}.yml
IMAGE_REPO="$$(cat /workspace/.image-repo)"
# MY_IP gets a matching host-nftables accept rule via extra_ssh_cidrs;
# close_hole() removes the Cloud Firewall side after Ansible exits but
# the nftables rule will be removed on the next deploy when the var
# is no longer set (i.e. operator-local deploys revert to WG-only).
ANSIBLE_HOST_KEY_CHECKING=False \
ansible-playbook -i inventory/${_PULUMI_STACK}.yml playbook.yml \
--extra-vars "caddy_image=$$IMAGE_REPO:latest" \
--extra-vars "{\"nftables_extra_ssh_cidrs\":[\"$$MY_IP\"]}"
options:
machineType: E2_HIGHCPU_8
logging: CLOUD_LOGGING_ONLY
timeout: 1800s