Files
Gitea/infra/pkg/build/build.go
JMR-devandClaude Opus 5 c0382d5d31 Gitea on GCE: podman quadlets, Pulumi, Cloud Build
Self-hosted Gitea on a single e2-small AlmaLinux 10 VM in us-east1,
serving gitea.jasonmross.dev.

Runtime is podman quadlets (systemd .container/.network/.volume units).
Both images are built on Debian 13: Gitea from a GPG-verified release
binary, and Caddy from an xcaddy build carrying the Google Cloud DNS
provider (ACME DNS-01) and the Coraza WAF with the OWASP CRS embedded.

Infrastructure is a Pulumi program in Go against a GCS state backend.
Cloud Build handles CI: a push trigger for images, one for infra, and a
weekly scheduled rebuild. Everything Cloud Build touches is 2nd gen.

Notable design decisions, each documented where it lives:

- Quadlets track a floating :prod tag. AutoUpdate=registry compares
  digests for a tag, so a digest-pinned image silently disables
  auto-updates.
- Git transport and LFS bypass the WAF. With the bypass removed, a plain
  git push returns 403 -- packfiles trip CRS reliably.
- gitea:wafMode drives both SecRuleEngine and whether the fail2ban jail
  acting on WAF verdicts exists. Banning on detections that were never
  blocks would turn a tuning false positive into an nftables ban.
- fail2ban bans at the nftables prerouting hook. Published container
  ports are DNAT'd and never traverse INPUT, where the stock actions
  install their rules.
- The DNS zone, backup bucket, and Gitea signing secrets are not
  Pulumi-owned, so pulumi destroy cannot take them with it.
- The podman subnet is pinned because it is what Gitea's
  REVERSE_PROXY_TRUSTED_PROXIES names.

Three update layers: dnf5-automatic for the OS, podman-auto-update with
health-gated rollback for containers, and a weekly image rebuild that
gives the second layer something to pull.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:45:33 -05:00

225 lines
9.0 KiB
Go

// Package build wires Cloud Build to the source repository and schedules the
// weekly image rebuild.
//
// The weekly rebuild is not optional garnish: podman's AutoUpdate=registry only
// pulls when the :prod tag's digest changes, so without something pushing a new
// image the container-update layer has nothing to do. The rebuild is what turns
// Debian and Go security fixes into a running container.
package build
import (
"encoding/base64"
"fmt"
"strings"
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudbuild"
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudbuildv2"
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudscheduler"
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/projects"
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/secretmanager"
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/serviceaccount"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"gitea-infra/pkg/config"
)
type Triggers struct {
Image *cloudbuild.Trigger
Infra *cloudbuild.Trigger
}
// New creates the GitHub connection, the two push triggers, and the scheduled
// rebuild. Returns nil (without error) when GitHub is not configured yet, so a
// fresh stack can be brought up before the GitHub App install is finished.
func New(
ctx *pulumi.Context,
cfg *config.Config,
imageSA *serviceaccount.Account,
deps []pulumi.Resource,
) (*Triggers, error) {
if !cfg.GitHubConfigured() {
ctx.Log.Warn("gitea:githubOwner/githubRepo/githubAppInstallationId not set -- skipping Cloud Build triggers", nil)
return nil, nil
}
opts := pulumi.DependsOn(deps)
// The PAT is created out of band (it is an OAuth artifact, not
// infrastructure) and referenced by version.
patVersion := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", cfg.Project, cfg.GitHubPATSecret)
// A 2nd-gen connection is read by the Cloud Build SERVICE AGENT, not by the
// build service account and not by whoever runs Pulumi. Without this grant,
// creating the connection fails with a permission error on the PAT secret.
//
// ServiceIdentity both materialises the agent (it may not exist yet on a
// fresh project) and hands back its email, which avoids having to look up
// the project number just to spell out
// service-{number}@gcp-sa-cloudbuild.iam.gserviceaccount.com.
agent, err := projects.NewServiceIdentity(ctx, "cloudbuild-agent", &projects.ServiceIdentityArgs{
Project: pulumi.String(cfg.Project),
Service: pulumi.String("cloudbuild.googleapis.com"),
}, opts)
if err != nil {
return nil, err
}
patAccess, err := secretmanager.NewSecretIamMember(ctx, "cloudbuild-agent-pat", &secretmanager.SecretIamMemberArgs{
Project: pulumi.String(cfg.Project),
SecretId: pulumi.String(cfg.GitHubPATSecret),
Role: pulumi.String("roles/secretmanager.secretAccessor"),
Member: pulumi.Sprintf("serviceAccount:%s", agent.Email),
}, opts)
if err != nil {
return nil, err
}
conn, err := cloudbuildv2.NewConnection(ctx, "github", &cloudbuildv2.ConnectionArgs{
Name: pulumi.String("github"),
Location: pulumi.String(cfg.Region),
GithubConfig: &cloudbuildv2.ConnectionGithubConfigArgs{
AppInstallationId: pulumi.Int(cfg.GitHubInstallationID),
AuthorizerCredential: &cloudbuildv2.ConnectionGithubConfigAuthorizerCredentialArgs{
OauthTokenSecretVersion: pulumi.String(patVersion),
},
},
}, opts, pulumi.DependsOn([]pulumi.Resource{patAccess}))
if err != nil {
return nil, err
}
repo, err := cloudbuildv2.NewRepository(ctx, "gitea-repo-link", &cloudbuildv2.RepositoryArgs{
Name: pulumi.String(cfg.GitHubRepo),
Location: pulumi.String(cfg.Region),
ParentConnection: conn.ID(),
RemoteUri: pulumi.Sprintf("https://github.com/%s/%s.git", cfg.GitHubOwner, cfg.GitHubRepo),
}, opts)
if err != nil {
return nil, err
}
imageTrigger, err := cloudbuild.NewTrigger(ctx, "gitea-image", &cloudbuild.TriggerArgs{
Name: pulumi.String("gitea-image"),
Location: pulumi.String(cfg.Region),
Description: pulumi.String("Build and roll out the Gitea and Caddy images"),
RepositoryEventConfig: &cloudbuild.TriggerRepositoryEventConfigArgs{
Repository: repo.ID(),
Push: &cloudbuild.TriggerRepositoryEventConfigPushArgs{Branch: pulumi.String("^main$")},
},
Filename: pulumi.String("cloudbuild/image.yaml"),
// Only rebuild when something that affects the image changed.
IncludedFiles: pulumi.ToStringArray([]string{"image/**", "cloudbuild/image.yaml"}),
ServiceAccount: imageSA.ID(),
Substitutions: pulumi.StringMap{
"_REGION": pulumi.String(cfg.Region),
"_ZONE": pulumi.String(cfg.Zone),
"_DOMAIN": pulumi.String(cfg.Domain),
},
}, opts)
if err != nil {
return nil, err
}
// The infra trigger runs Pulumi, so it uses the bootstrap-created account
// with the broad permissions -- deliberately a different identity from the
// one that merely pushes images.
infraSA := cfg.InfraBuildServiceAccount
if infraSA == "" || strings.Contains(infraSA, "CHANGEME") {
// Fail here rather than letting Cloud Build reject a malformed service
// account path at apply time with an opaque error.
return nil, fmt.Errorf("gitea:infraBuildServiceAccount is not set -- " +
"scripts/bootstrap.sh prints the value to use")
}
infraTrigger, err := cloudbuild.NewTrigger(ctx, "gitea-infra", &cloudbuild.TriggerArgs{
Name: pulumi.String("gitea-infra"),
Location: pulumi.String(cfg.Region),
Description: pulumi.String("pulumi up, then push VM config to the instance"),
RepositoryEventConfig: &cloudbuild.TriggerRepositoryEventConfigArgs{
Repository: repo.ID(),
Push: &cloudbuild.TriggerRepositoryEventConfigPushArgs{Branch: pulumi.String("^main$")},
},
Filename: pulumi.String("cloudbuild/infra.yaml"),
IncludedFiles: pulumi.ToStringArray([]string{"infra/**", "vm/**", "cloudbuild/infra.yaml"}),
ServiceAccount: pulumi.Sprintf("projects/%s/serviceAccounts/%s",
cfg.Project, infraSA),
Substitutions: pulumi.StringMap{
"_REGION": pulumi.String(cfg.Region),
"_ZONE": pulumi.String(cfg.Zone),
},
}, opts)
if err != nil {
return nil, err
}
if err := scheduleRebuild(ctx, cfg, imageSA, imageTrigger, opts); err != nil {
return nil, err
}
return &Triggers{Image: imageTrigger, Infra: infraTrigger}, nil
}
// scheduleRebuild re-runs the image trigger weekly so the :prod tag picks up
// Debian, Go, and Caddy security fixes without anyone opening a PR.
func scheduleRebuild(
ctx *pulumi.Context,
cfg *config.Config,
imageSA *serviceaccount.Account,
trigger *cloudbuild.Trigger,
opts pulumi.ResourceOption,
) error {
// Running a trigger is a write against the Cloud Build API.
if _, err := projects.NewIAMMember(ctx, "img-builds-editor", &projects.IAMMemberArgs{
Project: pulumi.String(cfg.Project),
Role: pulumi.String("roles/cloudbuild.builds.editor"),
Member: pulumi.Sprintf("serviceAccount:%s", imageSA.Email),
}, opts); err != nil {
return err
}
// v1 .../locations/{region}/triggers/{id}:run -- the regional (2nd-gen)
// endpoint. The global .../projects/{p}/triggers/{id}:run path is the
// 1st-gen one and would not find a regional trigger.
_, err := cloudscheduler.NewJob(ctx, "gitea-weekly-rebuild", &cloudscheduler.JobArgs{
Name: pulumi.String("gitea-weekly-rebuild"),
Region: pulumi.String(cfg.Region),
Description: pulumi.String("Weekly rebuild of the Gitea and Caddy images for base-OS security fixes"),
// Sunday 04:00 UTC -- an hour before the maintenance reboot window, so a
// fresh image is waiting when the box restarts.
Schedule: pulumi.String("0 4 * * 0"),
TimeZone: pulumi.String("Etc/UTC"),
HttpTarget: &cloudscheduler.JobHttpTargetArgs{
HttpMethod: pulumi.String("POST"),
Uri: pulumi.Sprintf("https://cloudbuild.googleapis.com/v1/projects/%s/locations/%s/triggers/%s:run",
cfg.Project, cfg.Region, trigger.TriggerId),
// Empty body, deliberately.
//
// RunBuildTriggerRequest.source is a RepoSource -- a 1st-generation
// shape that names a Cloud Source Repositories repo by project and
// repo name. There is no way to express a 2nd-gen repository
// (projects/*/locations/*/connections/*/repositories/*) in it, and
// these triggers are 2nd-gen: they carry repositoryEventConfig.
//
// source is optional, and omitting it tells Cloud Build to use the
// trigger's own configured repository and branch -- which is exactly
// what a scheduled rebuild of main wants. This is the REST
// equivalent of `gcloud builds triggers run TRIGGER --region=...`
// with no --branch/--tag/--sha, all three of which are optional.
Body: pulumi.String(base64JSON(`{}`)),
Headers: pulumi.StringMap{
"Content-Type": pulumi.String("application/json"),
},
OauthToken: &cloudscheduler.JobHttpTargetOauthTokenArgs{
ServiceAccountEmail: imageSA.Email,
Scope: pulumi.String("https://www.googleapis.com/auth/cloud-platform"),
},
},
}, opts)
return err
}
// base64JSON encodes a Cloud Scheduler HTTP body. The API takes the body as a
// base64 string, and passing raw JSON fails at apply time with an unhelpful
// error rather than at plan time.
func base64JSON(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}