First deploy: fix Pulumi, image build, container DNS and DNS-01; Dependabot updates #2

Merged
JMR-dev merged 11 commits from deploy-prep into main 2026-10-10 09:04:19 +00:00
10 changed files with 67 additions and 21 deletions
Showing only changes of commit f0e7a3b66f - Show all commits
+14 -5
View File
@@ -47,14 +47,23 @@ printf %s '<token>' | gcloud secrets versions add github-pat --data-file=- --pro
# 3. Confirm the Cloud DNS zone is authoritative. DNS-01 cannot work otherwise.
dig NS gitea.jasonmross.dev
# 4. Configure and apply.
# 4. The ACME contact address. Kept in Secret Manager, not stack config, so it
# stays out of this public repo; the VM reads it when rendering the Caddyfile.
printf %s 'you@example.com' | gcloud secrets versions add gitea-acme-email --data-file=- --project <project-id>
# 5. Configure and apply. Pulumi's GCS backend and Google provider use
# Application Default Credentials, not your gcloud login.
gcloud auth application-default login
# Use the passphrase bootstrap.sh generated. Letting `stack init` prompt for a
# new one encrypts the stack with a key Cloud Build's infra trigger never sees.
export PULUMI_CONFIG_PASSPHRASE=$(gcloud secrets versions access latest \
--secret=pulumi-config-passphrase --project <project-id>)
cd infra
pulumi login gs://<project-id>-pulumi-state
pulumi stack init prod
pulumi config set gcp:project <project-id>
pulumi config set gitea:domain gitea.jasonmross.dev
pulumi config set gitea:dnsZone <cloud-dns-managed-zone-name> # gcloud dns managed-zones list
pulumi config set gitea:acmeEmail you@example.com
pulumi config set gitea:githubOwner <owner>
pulumi config set gitea:githubAppInstallationId <id>
pulumi config set gitea:infraBuildServiceAccount cb-infra@<project-id>.iam.gserviceaccount.com
@@ -62,10 +71,10 @@ pulumi config set gitea:infraBuildServiceAccount cb-infra@<project-id>.iam.gserv
pulumi config set gitea:wafMode DetectionOnly
pulumi up
# 5. First image build. Until this runs, the :prod images do not exist.
# 6. First image build. Until this runs, the :prod images do not exist.
cd .. && make build
# 6. Create the admin user.
# 7. Create the admin user.
make ssh
sudo podman exec -u 1000 gitea gitea admin user create \
-c /etc/gitea/app.ini --admin --username <you> --email <you@example.com> --random-password
@@ -73,7 +82,7 @@ sudo podman exec -u 1000 gitea gitea admin user create \
### Expected on the first run, not a bug
Between step 4 and step 5 the `:prod` images do not exist yet, so `gitea.service`
Between step 5 and step 6 the `:prod` images do not exist yet, so `gitea.service`
and `caddy.service` crash-loop. That is intentional: the units carry
`Restart=always` with `StartLimitIntervalSec=0`, so they recover on their own
within 30 seconds of the first successful push. Likewise, `app.ini` is not
-1
View File
@@ -11,7 +11,6 @@ config:
# The Cloud DNS *resource* name of the existing managed zone, which is not
# necessarily the DNS name. `gcloud dns managed-zones list` to find it.
gitea:dnsZone: CHANGEME-managed-zone-name
gitea:acmeEmail: CHANGEME@example.com
# us-east1 has zones b, c and d -- there is no us-east1-a.
gitea:zone: us-east1-b
+3
View File
@@ -63,6 +63,9 @@ func main() {
if err := iam.GrantSecrets(ctx, cfg, accounts, secrets.Names); err != nil {
return err
}
if err := iam.GrantSecretRead(ctx, cfg, accounts, secrets.ACMEEmail); err != nil {
return err
}
buckets, err := storage.New(ctx, cfg, vmDir, apis)
if err != nil {
-1
View File
@@ -169,7 +169,6 @@ func New(
"image-caddy": pulumi.String(imageCaddy),
"domain": pulumi.String(cfg.Domain),
"acme-email": pulumi.String(cfg.ACMEEmail),
"app-name": pulumi.String(cfg.AppName),
"require-signin-view": pulumi.String(strconv.FormatBool(cfg.RequireSigninView)),
-2
View File
@@ -18,7 +18,6 @@ type Config struct {
Domain string
DNSZone string
ACMEEmail string
AppName string
PodmanCIDR string
@@ -51,7 +50,6 @@ func Load(ctx *pulumi.Context) (*Config, error) {
Zone: c.Get("zone"),
Domain: c.Require("domain"),
DNSZone: c.Require("dnsZone"),
ACMEEmail: c.Require("acmeEmail"),
AppName: c.Get("appName"),
PodmanCIDR: c.Get("podmanSubnet"),
+14 -6
View File
@@ -123,12 +123,7 @@ func GrantRegistry(ctx *pulumi.Context, cfg *config.Config, a *Accounts, repo *a
// scripts/bootstrap.sh, not by Pulumi; see package secrets for why.
func GrantSecrets(ctx *pulumi.Context, cfg *config.Config, a *Accounts, names []string) error {
for _, name := range names {
if _, err := secretmanager.NewSecretIamMember(ctx, "vm-read-"+name, &secretmanager.SecretIamMemberArgs{
Project: pulumi.String(cfg.Project),
SecretId: pulumi.String(name),
Role: pulumi.String("roles/secretmanager.secretAccessor"),
Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email),
}); err != nil {
if err := GrantSecretRead(ctx, cfg, a, name); err != nil {
return err
}
// vm/bootstrap.sh's safety net adds a version if one is somehow missing.
@@ -144,6 +139,19 @@ func GrantSecrets(ctx *pulumi.Context, cfg *config.Config, a *Accounts, names []
return nil
}
// GrantSecretRead gives the VM read-only access to one secret. On its own it is
// for operator-supplied values the VM must never write; GrantSecrets adds
// version-adder on top for the ones it may generate.
func GrantSecretRead(ctx *pulumi.Context, cfg *config.Config, a *Accounts, name string) error {
_, err := secretmanager.NewSecretIamMember(ctx, "vm-read-"+name, &secretmanager.SecretIamMemberArgs{
Project: pulumi.String(cfg.Project),
SecretId: pulumi.String(name),
Role: pulumi.String("roles/secretmanager.secretAccessor"),
Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email),
})
return err
}
// GrantBuckets: read-only on config, write-only on backups. The VM can create a
// backup but cannot read or delete existing ones, which limits what ransomware
// on the box could do to the backup history.
+6
View File
@@ -26,3 +26,9 @@ var Names = []string{
"gitea-oauth2-jwt-secret",
"gitea-lfs-jwt-secret",
}
// ACMEEmail holds the contact address Caddy registers with Let's Encrypt. It
// is a secret not because it signs anything but to keep it out of this public
// repository and out of instance metadata. Unlike Names it is supplied by the
// operator, never generated, so the VM gets read access only.
const ACMEEmail = "gitea-acme-email"
+11 -1
View File
@@ -99,6 +99,16 @@ if ! has_version github-pat; then
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.
@@ -247,7 +257,7 @@ Next:
pulumi stack init prod
pulumi config set gcp:project ${PROJECT}
pulumi config set gitea:infraBuildServiceAccount ${INFRA_SA_EMAIL}
# ...plus domain, dnsZone, acmeEmail, githubOwner, githubAppInstallationId
# ...plus domain, dnsZone, githubOwner, githubAppInstallationId
pulumi up
4. make build # or, spelled out:
gcloud builds submit --config cloudbuild/image.yaml --project ${PROJECT} \\
+18 -4
View File
@@ -36,7 +36,6 @@ AR_HOST=$(meta ar-host)
IMAGE_GITEA=$(meta image-gitea)
IMAGE_CADDY=$(meta image-caddy)
DOMAIN=$(meta domain)
ACME_EMAIL=$(meta acme-email)
APP_NAME=$(meta app-name)
PODMAN_SUBNET=$(meta podman-subnet)
PODMAN_GATEWAY=$(meta podman-gateway)
@@ -57,7 +56,7 @@ case "${WAF_MODE}" in
esac
: "${DATA_DISK_DEVICE:=/dev/disk/by-id/google-gitea-data}"
export GCP_PROJECT AR_HOST IMAGE_GITEA IMAGE_CADDY DOMAIN ACME_EMAIL APP_NAME
export GCP_PROJECT AR_HOST IMAGE_GITEA IMAGE_CADDY DOMAIN APP_NAME
export PODMAN_SUBNET PODMAN_GATEWAY REQUIRE_SIGNIN_VIEW WAF_MODE
# ---------------------------------------------------------------------------
@@ -429,6 +428,20 @@ render_all() {
rm -f /etc/sysctl.d/90-gitea-caddy.conf
fi
# The ACME contact address lives in Secret Manager rather than instance
# metadata, to keep it out of the public repository. Without it Caddy still
# issues certificates, just under an account with no contact address -- far
# better than an empty `email` directive, which fails to parse and leaves
# nothing serving TLS.
local acme_email caddy_email
if acme_email=$(gcloud secrets versions access latest --secret=gitea-acme-email \
--project="${GCP_PROJECT}" 2>/dev/null) && [[ -n "${acme_email}" ]]; then
caddy_email="email ${acme_email}"
else
warn "secret gitea-acme-email unreadable -- Caddy will register without a contact address"
caddy_email="# no ACME contact address: secret gitea-acme-email was unreadable at render time"
fi
# Trust both the bridge CIDR and loopback so this value stays correct in
# either Caddy networking mode. Rootful podman SNATs host-loopback traffic
# to the bridge gateway, so the CIDR covers the host-network case too.
@@ -444,7 +457,8 @@ render_all() {
GITEA_UPSTREAM="${gitea_upstream}" \
CADDY_NETWORK="${caddy_network}" \
CADDY_PUBLISH_PORTS="${caddy_publish}" \
CADDY_SYSCTL="${caddy_sysctl}"
CADDY_SYSCTL="${caddy_sysctl}" \
CADDY_EMAIL="${caddy_email}"
# app.ini is 0400 owned by uid 1000: it holds SECRET_KEY and INTERNAL_TOKEN,
# and the container runs as that uid and must be able to read it.
@@ -453,7 +467,7 @@ render_all() {
&& changed=1
render "${STATE_DIR}/config/Caddyfile.tmpl" /etc/caddy/Caddyfile root:root 0644 \
'${DOMAIN} ${ACME_EMAIL} ${GITEA_UPSTREAM} ${WAF_MODE}' \
'${DOMAIN} ${CADDY_EMAIL} ${GITEA_UPSTREAM} ${WAF_MODE}' \
&& changed=1
local unit
+1 -1
View File
@@ -5,7 +5,7 @@
# googleclouddns -- ACME DNS-01, so issuance and renewal never need inbound 80
# coraza_waf -- OWASP Coraza with the Core Rule Set embedded in the binary
{
email ${ACME_EMAIL}
${CADDY_EMAIL}
admin 127.0.0.1:2019
# Required by coraza-caddy: Caddy has no built-in ordering for a third-party
# directive, and the WAF must run before anything that could act on the