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>
225 lines
9.0 KiB
Go
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))
|
|
}
|