// Package config loads repospawner runtime configuration from the environment. package config import ( "fmt" "os" "strings" ) // Config is the fully-resolved repospawner configuration. The same struct is // loaded by the server and by the job subcommands, which run from the same // image with the same environment. type Config struct { // Listen is the HTTP listen address, e.g. ":8080". Listen string // Namespace is where request Jobs are created and looked up. Namespace string // Image is repospawner's own image reference; Jobs run it with a // different argv, so the deployment must pass its own tag through. Image string // JobServiceAccount is the service account the request Jobs run as; it // carries the projected vault-audience token. JobServiceAccount string // GiteaURL is the forge base URL, e.g. https://git.unkin.net. GiteaURL string // TFGitRepo is the "owner/name" of the terraform-git repository whose // config tree owns repository definitions. TFGitRepo string // VaultAddr, VaultK8sMount and VaultK8sRole drive the native kubernetes // auth login used to mint short-lived Gitea tokens. VaultAddr string VaultK8sMount string VaultK8sRole string // VaultSATokenPath is the projected service account token with the // "vault" audience. VaultSATokenPath string // GiteaCredsPath is the vault path of the dynamic Gitea credential. GiteaCredsPath string // WoodpeckerServer is the CI server base URL. WoodpeckerServer string // WoodpeckerTokenFile holds the Woodpecker API token. Absent means // Woodpecker enablement is unavailable, not fatal. WoodpeckerTokenFile string // WoodpeckerSecret is the Secret the enablement Job mounts to obtain // WoodpeckerTokenFile; the server reads the same file to decide whether // enablement is offered at all. WoodpeckerSecret string // GroupsHeader is the oauth2-proxy header carrying Authentik group names. GroupsHeader string // AllowedGroups gates every page load and API call. Never empty. AllowedGroups []string } // Load resolves configuration from the environment, failing closed on an empty // allow-list (an empty list would authorize nobody or, worse, be read as // "anyone" by a future refactor). func Load() (*Config, error) { c := &Config{ Listen: envOr("REPOSPAWNER_LISTEN", ":8080"), Namespace: envOr("REPOSPAWNER_NAMESPACE", "repospawner"), Image: envOr("REPOSPAWNER_IMAGE", ""), JobServiceAccount: envOr("REPOSPAWNER_JOB_SERVICE_ACCOUNT", "repospawner"), GiteaURL: envOr("GITEA_URL", "https://git.unkin.net"), TFGitRepo: envOr("REPOSPAWNER_TFGIT_REPO", "unkin/terraform-git"), VaultAddr: envOr("VAULT_ADDR", "https://vault.service.consul:8200"), VaultK8sMount: envOr("REPOSPAWNER_VAULT_K8S_MOUNT", "k8s/au/syd1"), VaultK8sRole: envOr("REPOSPAWNER_VAULT_K8S_ROLE", "repospawner"), VaultSATokenPath: envOr("REPOSPAWNER_VAULT_SA_TOKEN_PATH", "/var/run/secrets/vault/token"), GiteaCredsPath: envOr("REPOSPAWNER_GITEA_CREDS_PATH", "gitea/creds/repospawner"), WoodpeckerServer: envOr("WOODPECKER_SERVER", "https://ci.k8s.syd1.au.unkin.net"), WoodpeckerTokenFile: envOr("REPOSPAWNER_WOODPECKER_TOKEN_FILE", "/etc/repospawner/woodpecker/token"), WoodpeckerSecret: envOr("REPOSPAWNER_WOODPECKER_SECRET", "repospawner-woodpecker"), GroupsHeader: envOr("REPOSPAWNER_GROUPS_HEADER", "X-Forwarded-Groups"), AllowedGroups: ParseGroups(envOr("REPOSPAWNER_ALLOWED_GROUPS", "akP-repospawner-user")), } if len(c.AllowedGroups) == 0 { return nil, fmt.Errorf("REPOSPAWNER_ALLOWED_GROUPS must name at least one group") } if strings.TrimSpace(c.GroupsHeader) == "" { return nil, fmt.Errorf("REPOSPAWNER_GROUPS_HEADER must not be empty") } if strings.TrimSpace(c.Namespace) == "" { return nil, fmt.Errorf("REPOSPAWNER_NAMESPACE must not be empty") } if strings.TrimSpace(c.JobServiceAccount) == "" { return nil, fmt.Errorf("REPOSPAWNER_JOB_SERVICE_ACCOUNT must not be empty") } if strings.TrimSpace(c.VaultK8sMount) == "" || strings.TrimSpace(c.VaultK8sRole) == "" { return nil, fmt.Errorf("REPOSPAWNER_VAULT_K8S_MOUNT and REPOSPAWNER_VAULT_K8S_ROLE must not be empty") } if strings.TrimSpace(c.GiteaCredsPath) == "" { return nil, fmt.Errorf("REPOSPAWNER_GITEA_CREDS_PATH must not be empty") } if err := requireHTTP("GITEA_URL", c.GiteaURL); err != nil { return nil, err } if err := requireHTTP("VAULT_ADDR", c.VaultAddr); err != nil { return nil, err } if err := requireHTTP("WOODPECKER_SERVER", c.WoodpeckerServer); err != nil { return nil, err } if _, _, err := SplitRepo(c.TFGitRepo); err != nil { return nil, fmt.Errorf("REPOSPAWNER_TFGIT_REPO: %w", err) } return c, nil } // SplitRepo splits an "owner/name" reference. func SplitRepo(s string) (owner, name string, err error) { owner, name, ok := strings.Cut(strings.TrimSpace(s), "/") if !ok || owner == "" || name == "" || strings.Contains(name, "/") { return "", "", fmt.Errorf("%q is not owner/name", s) } return owner, name, nil } // ParseGroups splits a group list tolerating both comma and whitespace // separation, dropping empties. oauth2-proxy emits comma-separated groups but // deployments hand-write the allow-list. func ParseGroups(s string) []string { fields := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ';' }) out := make([]string, 0, len(fields)) for _, f := range fields { if f = strings.TrimSpace(f); f != "" { out = append(out, f) } } return out } func requireHTTP(name, val string) error { if !strings.HasPrefix(val, "http://") && !strings.HasPrefix(val, "https://") { return fmt.Errorf("%s %q must be an http(s) URL", name, val) } return nil } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def }