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.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
for _, tc := range []struct{ name, got, want string }{
|
||||
{"Listen", cfg.Listen, ":8080"},
|
||||
{"Namespace", cfg.Namespace, "repospawner"},
|
||||
{"JobServiceAccount", cfg.JobServiceAccount, "repospawner"},
|
||||
{"GiteaURL", cfg.GiteaURL, "https://git.unkin.net"},
|
||||
{"TFGitRepo", cfg.TFGitRepo, "unkin/terraform-git"},
|
||||
{"VaultAddr", cfg.VaultAddr, "https://vault.service.consul:8200"},
|
||||
{"VaultK8sMount", cfg.VaultK8sMount, "k8s/au/syd1"},
|
||||
{"VaultK8sRole", cfg.VaultK8sRole, "repospawner"},
|
||||
{"GiteaCredsPath", cfg.GiteaCredsPath, "gitea/creds/repospawner"},
|
||||
{"WoodpeckerServer", cfg.WoodpeckerServer, "https://ci.k8s.syd1.au.unkin.net"},
|
||||
{"WoodpeckerTokenFile", cfg.WoodpeckerTokenFile, "/etc/repospawner/woodpecker/token"},
|
||||
{"GroupsHeader", cfg.GroupsHeader, "X-Forwarded-Groups"},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
if len(cfg.AllowedGroups) != 1 {
|
||||
t.Errorf("AllowedGroups = %v", cfg.AllowedGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFailsClosed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
}{
|
||||
{name: "empty group list", env: map[string]string{"REPOSPAWNER_ALLOWED_GROUPS": " , ,"}},
|
||||
{name: "blank groups header", env: map[string]string{"REPOSPAWNER_GROUPS_HEADER": " "}},
|
||||
{name: "non-http gitea url", env: map[string]string{"GITEA_URL": "git.unkin.net"}},
|
||||
{name: "non-http vault addr", env: map[string]string{"VAULT_ADDR": "vault.service.consul:8200"}},
|
||||
{name: "non-http woodpecker", env: map[string]string{"WOODPECKER_SERVER": "ci.unkin.net"}},
|
||||
{name: "bad tfgit repo", env: map[string]string{"REPOSPAWNER_TFGIT_REPO": "terraform-git"}},
|
||||
{name: "nested tfgit repo", env: map[string]string{"REPOSPAWNER_TFGIT_REPO": "a/b/c"}},
|
||||
{name: "blank namespace", env: map[string]string{"REPOSPAWNER_NAMESPACE": " "}},
|
||||
{name: "blank vault role", env: map[string]string{"REPOSPAWNER_VAULT_K8S_ROLE": " "}},
|
||||
{name: "blank creds path", env: map[string]string{"REPOSPAWNER_GITEA_CREDS_PATH": " "}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("Load() with %v should have failed", tc.env)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRepo(t *testing.T) {
|
||||
owner, name, err := SplitRepo(" unkin/terraform-git ")
|
||||
if err != nil || owner != "unkin" || name != "terraform-git" {
|
||||
t.Fatalf("SplitRepo = %q %q %v", owner, name, err)
|
||||
}
|
||||
for _, bad := range []string{"", "unkin", "/name", "owner/", "a/b/c"} {
|
||||
if _, _, err := SplitRepo(bad); err == nil {
|
||||
t.Errorf("SplitRepo(%q) should have failed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGroups(t *testing.T) {
|
||||
got := ParseGroups("a, b;c\nd e")
|
||||
want := []string{"a", "b", "c", "d", "e"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("ParseGroups = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ParseGroups = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
if len(ParseGroups(" ,, ")) != 0 {
|
||||
t.Errorf("ParseGroups of separators only should be empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user