Files
repospawner/internal/config/config.go
T
unkin-agent f1bcb8cd3a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add the initial repospawner service
repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
2026-08-30 14:33:31 +10:00

148 lines
5.8 KiB
Go

// 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
}