Files
JMR-devandClaude Opus 5.5 ec4de0c6c4 Run Gitea Actions jobs on a dedicated GKE cluster
gitea:runnersEnabled creates a zonal GKE Standard cluster, used for
nothing else, running the official gitea/runner image in rootless
Docker-in-Docker, so workflows can `docker build`. Off by default; with
the gate off the stack only gains the container API.

- private spot nodes, egress through Cloud NAT on a static IP; control
  plane reachable only through its IAM-gated DNS endpoint
- the registration token is a Secret Manager secret created by
  scripts/bootstrap.sh; Gitea reads it from a file, the runner pod
  mounts it through the Secret Manager CSI add-on with Workload
  Identity. Pulumi never sees the value
- a NetworkPolicy keeps job containers off the metadata server and
  private ranges, so jobs hold no GCP identity
- Caddy routes /api/actions, /api/actions_pipeline and the artifact
  service around the WAF (protobuf and PUT/PATCH uploads); fail2ban
  ignores the NAT address
- docs/runners.md: threat model, cost, enable/disable, token rotation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:31:15 +07:00

219 lines
6.8 KiB
Go

// Package config turns loose Pulumi stack config into one typed struct, so the
// rest of the program never reaches back into the config bag and every key has
// exactly one spelling.
package config
import (
"errors"
"fmt"
"strings"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)
type Config struct {
Project string
Region string
Zone string
Domain string
DNSZone string
AppName string
PodmanCIDR string
MachineType string
BootDiskGB int
DataDiskGB int
RequireSigninView bool
// WAFMode is the Coraza SecRuleEngine setting. It also decides whether the
// fail2ban jail that acts on WAF verdicts is enabled at all.
WAFMode string
GitHubOwner string
GitHubRepo string
GitHubInstallationID int
GitHubPATSecret string
InfraBuildServiceAccount string
// RunnersEnabled gates the whole GKE runner slice: the cluster, its network,
// and the runner workload. Off by default, because it depends on a secret
// that only exists after scripts/bootstrap.sh has been re-run.
RunnersEnabled bool
RunnerImageTag string
RunnerReplicas int
RunnerCapacity int
RunnerMachineType string
RunnerMaxNodes int
RunnerSpot bool
}
func Load(ctx *pulumi.Context) (*Config, error) {
gcp := config.New(ctx, "gcp")
c := config.New(ctx, "gitea")
cfg := &Config{
Project: gcp.Require("project"),
Region: gcp.Get("region"),
Zone: c.Get("zone"),
Domain: c.Require("domain"),
DNSZone: c.Require("dnsZone"),
AppName: c.Get("appName"),
PodmanCIDR: c.Get("podmanSubnet"),
MachineType: c.Get("machineType"),
BootDiskGB: c.GetInt("bootDiskGb"),
DataDiskGB: c.GetInt("dataDiskGb"),
RequireSigninView: c.GetBool("requireSigninView"),
WAFMode: c.Get("wafMode"),
GitHubOwner: c.Get("githubOwner"),
GitHubRepo: c.Get("githubRepo"),
GitHubInstallationID: c.GetInt("githubAppInstallationId"),
GitHubPATSecret: c.Get("githubPatSecret"),
InfraBuildServiceAccount: c.Get("infraBuildServiceAccount"),
RunnersEnabled: c.GetBool("runnersEnabled"),
RunnerImageTag: c.Get("runnerImageTag"),
RunnerCapacity: c.GetInt("runnerCapacity"),
RunnerMachineType: c.Get("runnerMachineType"),
RunnerMaxNodes: c.GetInt("runnerMaxNodes"),
}
// These two have non-zero defaults where zero/false is ALSO a meaningful
// setting (replicas=0 is the clean-teardown step; spot=false is on-demand
// nodes), so GetInt/GetBool -- which cannot tell "unset" from 0/false --
// would make the explicit value impossible to express.
var err error
if cfg.RunnerReplicas, err = c.TryInt("runnerReplicas"); err != nil {
if !errors.Is(err, config.ErrMissingVar) {
return nil, fmt.Errorf("gitea:runnerReplicas: %w", err)
}
cfg.RunnerReplicas = 1
}
if cfg.RunnerSpot, err = c.TryBool("runnerSpot"); err != nil {
if !errors.Is(err, config.ErrMissingVar) {
return nil, fmt.Errorf("gitea:runnerSpot: %w", err)
}
cfg.RunnerSpot = true
}
applyDefaults(cfg)
return cfg, validate(cfg)
}
func applyDefaults(c *Config) {
if c.Region == "" {
c.Region = "us-east1"
}
if c.Zone == "" {
// "-b", not "-a": us-east1 has zones b, c and d and NO -a zone, so the
// obvious default would generate an invalid zone in the very region this
// stack targets. "-b" exists in every US region we would plausibly use.
// Set gitea:zone explicitly if you care which one.
c.Zone = c.Region + "-b"
}
if c.AppName == "" {
c.AppName = "Gitea"
}
if c.PodmanCIDR == "" {
c.PodmanCIDR = "10.89.10.0/24"
}
if c.MachineType == "" {
c.MachineType = "e2-small"
}
if c.BootDiskGB == 0 {
c.BootDiskGB = 20
}
if c.DataDiskGB == 0 {
c.DataDiskGB = 30
}
if c.GitHubPATSecret == "" {
c.GitHubPATSecret = "github-pat"
}
if c.WAFMode == "" {
// Detect first, tune, then block. Starting a WAF in blocking mode in
// front of an app that legitimately carries code and markdown in POST
// bodies is how you get paged on day one.
c.WAFMode = "DetectionOnly"
}
if c.RunnerImageTag == "" {
// Pinned, not floating: the runner went 4.x -> 5.x in two weeks with
// breaking config changes. Bump deliberately, like image/gitea.version.
c.RunnerImageTag = "5.1.0-dind-rootless"
}
if c.RunnerCapacity == 0 {
c.RunnerCapacity = 1
}
if c.RunnerMachineType == "" {
// 2 vCPU / 8 GB: enough for a docker build of a Go service. Each runner
// pod is sized to fill one of these, so replicas map to nodes.
c.RunnerMachineType = "e2-standard-2"
}
if c.RunnerMaxNodes == 0 {
c.RunnerMaxNodes = 3
}
}
func validate(c *Config) error {
if !strings.Contains(c.Domain, ".") {
return fmt.Errorf("gitea:domain %q does not look like a hostname", c.Domain)
}
if !strings.HasPrefix(c.Zone, c.Region) {
return fmt.Errorf("gitea:zone %q is not in gcp:region %q", c.Zone, c.Region)
}
switch c.WAFMode {
case "On", "DetectionOnly", "Off":
default:
return fmt.Errorf("gitea:wafMode %q must be On, DetectionOnly, or Off", c.WAFMode)
}
if c.RunnersEnabled {
if c.RunnerMaxNodes < 1 || c.RunnerMaxNodes > 10 {
// The upper bound is a cost guard, not a technical limit.
return fmt.Errorf("gitea:runnerMaxNodes %d must be between 1 and 10", c.RunnerMaxNodes)
}
if c.RunnerReplicas < 0 || c.RunnerReplicas > c.RunnerMaxNodes {
// One runner pod per node by construction (see pkg/runner), so more
// replicas than nodes would leave pods Pending forever.
return fmt.Errorf("gitea:runnerReplicas %d must be between 0 and gitea:runnerMaxNodes (%d)",
c.RunnerReplicas, c.RunnerMaxNodes)
}
if c.RunnerCapacity < 1 {
return fmt.Errorf("gitea:runnerCapacity %d must be at least 1", c.RunnerCapacity)
}
if !strings.HasSuffix(c.RunnerImageTag, "-dind-rootless") {
// The pod spec hard-codes the rootless image's uid and paths
// (/home/rootless/...), and the security model depends on dockerd
// NOT running as root. A plain or -dind tag would start and then
// fail in confusing ways, or quietly run a root daemon.
return fmt.Errorf("gitea:runnerImageTag %q must be a -dind-rootless tag", c.RunnerImageTag)
}
}
return nil
}
// GitHubConfigured reports whether enough GitHub settings are present to wire up
// push triggers. Everything else still deploys without them, so a fresh stack can
// come up before the GitHub App install is done.
func (c *Config) GitHubConfigured() bool {
return c.GitHubOwner != "" &&
c.GitHubOwner != "CHANGEME" &&
c.GitHubRepo != "" &&
c.GitHubInstallationID > 0
}
// ARHost is the Artifact Registry endpoint for the configured region.
func (c *Config) ARHost() string { return c.Region + "-docker.pkg.dev" }
// RunnerImage is the Gitea Actions runner image the GKE runners use.
func (c *Config) RunnerImage() string { return "docker.io/gitea/runner:" + c.RunnerImageTag }
// FQDN returns the domain with a trailing dot, as Cloud DNS requires.
func (c *Config) FQDN() string { return c.Domain + "." }