dev-pod-api-build/internal/k8s/templates_test.go
2026-04-16 04:16:36 +00:00

540 lines
17 KiB
Go

package k8s
import (
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
var testCfg = Config{
Domain: "spinoff.dev",
Registry: "10.22.0.56:30500",
GoldenImage: "dev-golden:v2",
VPNGatewayNS: "claw-system",
VPNGatewaySecret: "vpn-gateway-secrets",
AnthropicKey: "br_test_anthropic",
OpenAIKey: "br_test_openai",
ForgejoURL: "http://forgejo.dev-infra.svc:3000",
}
var testOpts = PodOpts{
User: "alice",
Pod: "main",
Tools: "go@1.25,rust@1.94",
Task: "build a web server",
CPUReq: "2",
CPULimit: "4",
MemReq: "4Gi",
MemLimit: "8Gi",
VPNKey: "test-vpn-key",
AnthropicKey: "br_test_anthropic",
OpenAIKey: "br_test_openai",
}
func TestPVCTemplate(t *testing.T) {
pvc := PVCTemplate("alice", "main")
if pvc.Name != "workspace-main" {
t.Errorf("expected name workspace-main, got %s", pvc.Name)
}
if pvc.Namespace != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", pvc.Namespace)
}
if len(pvc.Spec.AccessModes) != 1 || pvc.Spec.AccessModes[0] != corev1.ReadWriteOnce {
t.Errorf("expected ReadWriteOnce access mode")
}
if *pvc.Spec.StorageClassName != "longhorn" {
t.Errorf("expected longhorn storage class, got %s", *pvc.Spec.StorageClassName)
}
storage := pvc.Spec.Resources.Requests[corev1.ResourceStorage]
if storage.Cmp(resource.MustParse("20Gi")) != 0 {
t.Errorf("expected 20Gi storage, got %s", storage.String())
}
if pvc.Labels["app"] != "dev-pod" {
t.Errorf("expected label app=dev-pod, got %s", pvc.Labels["app"])
}
if pvc.Labels["podname"] != "main" {
t.Errorf("expected label podname=main, got %s", pvc.Labels["podname"])
}
}
func TestPVCTemplatePerPod(t *testing.T) {
tests := []struct {
user string
pod string
wantPVC string
}{
{"alice", "main", "workspace-main"},
{"bob", "build1", "workspace-build1"},
{"alice", "runner-42", "workspace-runner-42"},
}
for _, tt := range tests {
t.Run(tt.user+"/"+tt.pod, func(t *testing.T) {
pvc := PVCTemplate(tt.user, tt.pod)
if pvc.Name != tt.wantPVC {
t.Errorf("expected PVC name %s, got %s", tt.wantPVC, pvc.Name)
}
if pvc.Labels["podname"] != tt.pod {
t.Errorf("expected podname label %s, got %s", tt.pod, pvc.Labels["podname"])
}
})
}
}
func TestPVCName(t *testing.T) {
if got := PVCName("main"); got != "workspace-main" {
t.Errorf("expected workspace-main, got %s", got)
}
if got := PVCName("build1"); got != "workspace-build1" {
t.Errorf("expected workspace-build1, got %s", got)
}
}
func TestPodTemplate(t *testing.T) {
pod := PodTemplate(testCfg, testOpts)
t.Run("metadata", func(t *testing.T) {
if pod.Name != "dev-pod-main" {
t.Errorf("expected name dev-pod-main, got %s", pod.Name)
}
if pod.Namespace != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", pod.Namespace)
}
if pod.Labels["app"] != "dev-pod" {
t.Errorf("expected label app=dev-pod, got %s", pod.Labels["app"])
}
if pod.Labels["podname"] != "main" {
t.Errorf("expected label podname=main, got %s", pod.Labels["podname"])
}
})
t.Run("host_aliases", func(t *testing.T) {
if len(pod.Spec.HostAliases) != 1 {
t.Fatalf("expected 1 host alias, got %d", len(pod.Spec.HostAliases))
}
ha := pod.Spec.HostAliases[0]
if ha.IP != "127.0.0.1" {
t.Errorf("expected IP 127.0.0.1, got %s", ha.IP)
}
if len(ha.Hostnames) != 2 {
t.Fatalf("expected 2 hostnames, got %d", len(ha.Hostnames))
}
if ha.Hostnames[0] != "anthropic.internal" || ha.Hostnames[1] != "openai.internal" {
t.Errorf("unexpected hostnames: %v", ha.Hostnames)
}
})
t.Run("containers_count", func(t *testing.T) {
if len(pod.Spec.Containers) != 3 {
t.Fatalf("expected 3 containers, got %d", len(pod.Spec.Containers))
}
})
t.Run("dev_container", func(t *testing.T) {
dev := pod.Spec.Containers[0]
if dev.Name != "dev" {
t.Errorf("expected container name dev, got %s", dev.Name)
}
if dev.Image != "10.22.0.56:30500/dev-golden:v2" {
t.Errorf("expected golden image, got %s", dev.Image)
}
if dev.ImagePullPolicy != corev1.PullAlways {
t.Errorf("expected PullAlways, got %s", dev.ImagePullPolicy)
}
if len(dev.Ports) != 5 {
t.Errorf("expected 5 ports, got %d", len(dev.Ports))
}
// Check resource requests
cpuReq := dev.Resources.Requests[corev1.ResourceCPU]
if cpuReq.Cmp(resource.MustParse("2")) != 0 {
t.Errorf("expected cpu req 2, got %s", cpuReq.String())
}
memLim := dev.Resources.Limits[corev1.ResourceMemory]
if memLim.Cmp(resource.MustParse("8Gi")) != 0 {
t.Errorf("expected mem limit 8Gi, got %s", memLim.String())
}
// Check env vars
envMap := make(map[string]string)
for _, e := range dev.Env {
envMap[e.Name] = e.Value
}
if envMap["TASK_DESCRIPTION"] != "build a web server" {
t.Errorf("unexpected TASK_DESCRIPTION: %s", envMap["TASK_DESCRIPTION"])
}
if envMap["DEV_TOOLS"] != "go@1.25,rust@1.94" {
t.Errorf("unexpected DEV_TOOLS: %s", envMap["DEV_TOOLS"])
}
if envMap["TTYD_BASE_PATH"] != "/@alice/main" {
t.Errorf("unexpected TTYD_BASE_PATH: %s", envMap["TTYD_BASE_PATH"])
}
if envMap["DEV_EXTERNAL_HOST"] != "spinoff.dev" {
t.Errorf("unexpected DEV_EXTERNAL_HOST: %s", envMap["DEV_EXTERNAL_HOST"])
}
if envMap["FORGEJO_URL"] != "http://forgejo.dev-infra.svc:3000" {
t.Errorf("unexpected FORGEJO_URL: %s", envMap["FORGEJO_URL"])
}
// Check envFrom references dev-secrets
if len(dev.EnvFrom) != 1 {
t.Fatalf("expected 1 envFrom, got %d", len(dev.EnvFrom))
}
if dev.EnvFrom[0].SecretRef.Name != "dev-secrets" {
t.Errorf("expected envFrom dev-secrets, got %s", dev.EnvFrom[0].SecretRef.Name)
}
// Check volume mount
if len(dev.VolumeMounts) != 1 || dev.VolumeMounts[0].MountPath != "/home/dev/workspace" {
t.Errorf("expected workspace mount at /home/dev/workspace")
}
})
t.Run("ai_proxy_container", func(t *testing.T) {
proxy := pod.Spec.Containers[1]
if proxy.Name != "ai-proxy" {
t.Errorf("expected container name ai-proxy, got %s", proxy.Name)
}
if proxy.Image != "nginx:alpine" {
t.Errorf("expected nginx:alpine, got %s", proxy.Image)
}
if len(proxy.VolumeMounts) != 2 {
t.Errorf("expected 2 volume mounts, got %d", len(proxy.VolumeMounts))
}
for _, vm := range proxy.VolumeMounts {
if !vm.ReadOnly {
t.Errorf("expected read-only mount for %s", vm.Name)
}
}
})
t.Run("ipip_sidecar_container", func(t *testing.T) {
ipip := pod.Spec.Containers[2]
if ipip.Name != "ipip-sidecar" {
t.Errorf("expected container name ipip-sidecar, got %s", ipip.Name)
}
if ipip.Image != "10.22.0.56:30500/claw-ipip-tunnel:dev" {
t.Errorf("expected ipip tunnel image, got %s", ipip.Image)
}
if ipip.SecurityContext == nil || !*ipip.SecurityContext.Privileged {
t.Error("expected privileged security context")
}
envMap := make(map[string]string)
for _, e := range ipip.Env {
if e.ValueFrom == nil {
envMap[e.Name] = e.Value
}
}
if envMap["POD_ID"] != "dev-alice-main" {
t.Errorf("unexpected POD_ID: %s", envMap["POD_ID"])
}
if envMap["VPN_GATEWAY_HOST"] != "vpn-gateway.claw-system.svc" {
t.Errorf("unexpected VPN_GATEWAY_HOST: %s", envMap["VPN_GATEWAY_HOST"])
}
// Check VPN_GATEWAY_KEY comes from secret ref
var vpnKeyEnv *corev1.EnvVar
for i := range ipip.Env {
if ipip.Env[i].Name == "VPN_GATEWAY_KEY" {
vpnKeyEnv = &ipip.Env[i]
break
}
}
if vpnKeyEnv == nil || vpnKeyEnv.ValueFrom == nil || vpnKeyEnv.ValueFrom.SecretKeyRef == nil {
t.Fatal("expected VPN_GATEWAY_KEY from secret ref")
}
if vpnKeyEnv.ValueFrom.SecretKeyRef.Name != "dev-secrets" {
t.Errorf("expected secret name dev-secrets, got %s", vpnKeyEnv.ValueFrom.SecretKeyRef.Name)
}
})
t.Run("volumes", func(t *testing.T) {
if len(pod.Spec.Volumes) != 3 {
t.Fatalf("expected 3 volumes, got %d", len(pod.Spec.Volumes))
}
volumeNames := make(map[string]bool)
for _, v := range pod.Spec.Volumes {
volumeNames[v.Name] = true
}
for _, name := range []string{"workspace", "ai-proxy-config", "ai-proxy-secrets"} {
if !volumeNames[name] {
t.Errorf("missing volume %s", name)
}
}
// Verify workspace volume references per-pod PVC
wsVol := pod.Spec.Volumes[0]
if wsVol.PersistentVolumeClaim.ClaimName != "workspace-main" {
t.Errorf("expected workspace PVC claim workspace-main, got %s", wsVol.PersistentVolumeClaim.ClaimName)
}
})
}
func TestServiceTemplate(t *testing.T) {
svc := ServiceTemplate("alice", "main")
if svc.Name != "dev-pod-main-svc" {
t.Errorf("expected name dev-pod-main-svc, got %s", svc.Name)
}
if svc.Namespace != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", svc.Namespace)
}
if svc.Spec.Type != corev1.ServiceTypeClusterIP {
t.Errorf("expected ClusterIP, got %s", svc.Spec.Type)
}
if len(svc.Spec.Ports) != 5 {
t.Errorf("expected 5 ports, got %d", len(svc.Spec.Ports))
}
portMap := make(map[string]int32)
for _, p := range svc.Spec.Ports {
portMap[p.Name] = p.Port
}
expected := map[string]int32{
"ttyd": 7681, "ssh": 22, "forgejo": 3000,
"vscode": 8080, "ralphex-gerrit": 8090,
}
for name, port := range expected {
if portMap[name] != port {
t.Errorf("expected port %s=%d, got %d", name, port, portMap[name])
}
}
if svc.Spec.Selector["app"] != "dev-pod" || svc.Spec.Selector["podname"] != "main" {
t.Errorf("unexpected selector: %v", svc.Spec.Selector)
}
}
func TestIngressTemplate(t *testing.T) {
objs := IngressTemplate("alice", "main", "spinoff.dev")
if len(objs) != 3 {
t.Fatalf("expected 3 objects (ingress + 2 middleware), got %d", len(objs))
}
t.Run("ingress_route", func(t *testing.T) {
ir := objs[0]
if ir.GetKind() != "IngressRoute" {
t.Errorf("expected kind IngressRoute, got %s", ir.GetKind())
}
if ir.GetName() != "dev-pod-main-ingress" {
t.Errorf("expected name dev-pod-main-ingress, got %s", ir.GetName())
}
if ir.GetNamespace() != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", ir.GetNamespace())
}
spec := ir.Object["spec"].(map[string]interface{})
routes := spec["routes"].([]interface{})
if len(routes) != 5 {
t.Errorf("expected 5 routes, got %d", len(routes))
}
// Check first route is ttyd (no explicit priority — Traefik v3 auto-calculates)
r0 := routes[0].(map[string]interface{})
if _, hasPriority := r0["priority"]; hasPriority {
t.Errorf("expected no explicit priority, got %v", r0["priority"])
}
if r0["match"] != "Host(`spinoff.dev`) && PathPrefix(`/@alice/main/`)" {
t.Errorf("unexpected match: %s", r0["match"])
}
// Check last route has strip prefix middleware
r4 := routes[4].(map[string]interface{})
middlewares := r4["middlewares"].([]interface{})
if len(middlewares) != 2 {
t.Errorf("expected 2 middlewares for ralphex route, got %d", len(middlewares))
}
})
t.Run("basic_auth_middleware", func(t *testing.T) {
mw := objs[1]
if mw.GetKind() != "Middleware" {
t.Errorf("expected kind Middleware, got %s", mw.GetKind())
}
if mw.GetName() != "spinoff-basic-auth" {
t.Errorf("expected name spinoff-basic-auth, got %s", mw.GetName())
}
})
t.Run("strip_prefix_middleware", func(t *testing.T) {
mw := objs[2]
if mw.GetName() != "strip-dev-main-ralphex-prefix" {
t.Errorf("expected name strip-dev-main-ralphex-prefix, got %s", mw.GetName())
}
spec := mw.Object["spec"].(map[string]interface{})
stripPrefix := spec["stripPrefix"].(map[string]interface{})
prefixes := stripPrefix["prefixes"].([]interface{})
if len(prefixes) != 1 || prefixes[0] != "/@alice/main/ralphex" {
t.Errorf("unexpected prefixes: %v", prefixes)
}
})
}
func TestSecretsTemplate(t *testing.T) {
devSec, aiSec := SecretsTemplate("alice", "vpn-key-123", "br_anthropic_real", "br_openai_real", "forgejo-tok-abc", "tskey-auth-abc123")
t.Run("dev_secrets", func(t *testing.T) {
if devSec.Name != "dev-secrets" {
t.Errorf("expected name dev-secrets, got %s", devSec.Name)
}
if devSec.Namespace != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", devSec.Namespace)
}
if devSec.StringData["ANTHROPIC_API_KEY"] != "sk-devpod" {
t.Errorf("expected dummy anthropic key, got %s", devSec.StringData["ANTHROPIC_API_KEY"])
}
if devSec.StringData["ANTHROPIC_BASE_URL"] != "http://anthropic.internal" {
t.Errorf("unexpected base url: %s", devSec.StringData["ANTHROPIC_BASE_URL"])
}
if devSec.StringData["BASEROUTE_OPENAI_KEY"] != "sk-devpod" {
t.Errorf("expected dummy openai key, got %s", devSec.StringData["BASEROUTE_OPENAI_KEY"])
}
if devSec.StringData["VPN_GATEWAY_KEY"] != "vpn-key-123" {
t.Errorf("expected vpn key vpn-key-123, got %s", devSec.StringData["VPN_GATEWAY_KEY"])
}
if devSec.StringData["FORGEJO_TOKEN"] != "forgejo-tok-abc" {
t.Errorf("expected forgejo token forgejo-tok-abc, got %s", devSec.StringData["FORGEJO_TOKEN"])
}
if devSec.StringData["TAILSCALE_AUTHKEY"] != "tskey-auth-abc123" {
t.Errorf("expected tailscale key tskey-auth-abc123, got %s", devSec.StringData["TAILSCALE_AUTHKEY"])
}
})
t.Run("ai_proxy_secrets", func(t *testing.T) {
if aiSec.Name != "ai-proxy-secrets" {
t.Errorf("expected name ai-proxy-secrets, got %s", aiSec.Name)
}
if aiSec.Namespace != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", aiSec.Namespace)
}
if aiSec.StringData["anthropic-key"] != "br_anthropic_real" {
t.Errorf("expected real anthropic key, got %s", aiSec.StringData["anthropic-key"])
}
if aiSec.StringData["openai-key"] != "br_openai_real" {
t.Errorf("expected real openai key, got %s", aiSec.StringData["openai-key"])
}
})
}
func TestSecretsTemplate_NoTailscaleKey(t *testing.T) {
devSec, _ := SecretsTemplate("alice", "vpn-key-123", "br_anthropic_real", "br_openai_real", "forgejo-tok-abc", "")
if _, exists := devSec.StringData["TAILSCALE_AUTHKEY"]; exists {
t.Error("TAILSCALE_AUTHKEY should not be present when tailscale key is empty")
}
}
func TestNetworkPolicyTemplate(t *testing.T) {
np := NetworkPolicyTemplate("alice")
if np.Name != "dev-pod-network-policy" {
t.Errorf("expected name dev-pod-network-policy, got %s", np.Name)
}
if np.Namespace != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", np.Namespace)
}
if len(np.Spec.PolicyTypes) != 2 {
t.Errorf("expected 2 policy types, got %d", len(np.Spec.PolicyTypes))
}
t.Run("ingress_rules", func(t *testing.T) {
if len(np.Spec.Ingress) != 1 {
t.Fatalf("expected 1 ingress rule, got %d", len(np.Spec.Ingress))
}
from := np.Spec.Ingress[0].From
if len(from) != 1 {
t.Fatalf("expected 1 from peer, got %d", len(from))
}
nsLabel := from[0].NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"]
if nsLabel != "kube-system" {
t.Errorf("expected kube-system, got %s", nsLabel)
}
})
t.Run("egress_rules", func(t *testing.T) {
if len(np.Spec.Egress) != 3 {
t.Fatalf("expected 3 egress rules, got %d", len(np.Spec.Egress))
}
// VPN gateway rule
vpnRule := np.Spec.Egress[0]
if len(vpnRule.To) != 1 {
t.Fatalf("expected 1 to peer in vpn rule, got %d", len(vpnRule.To))
}
nsLabel := vpnRule.To[0].NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"]
if nsLabel != "claw-system" {
t.Errorf("expected claw-system, got %s", nsLabel)
}
podLabel := vpnRule.To[0].PodSelector.MatchLabels["app"]
if podLabel != "vpn-gateway" {
t.Errorf("expected vpn-gateway, got %s", podLabel)
}
// dev-infra rule
devInfraRule := np.Spec.Egress[1]
nsLabel = devInfraRule.To[0].NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"]
if nsLabel != "dev-infra" {
t.Errorf("expected dev-infra, got %s", nsLabel)
}
// DNS rule
dnsRule := np.Spec.Egress[2]
if len(dnsRule.Ports) != 2 {
t.Errorf("expected 2 DNS ports, got %d", len(dnsRule.Ports))
}
})
}
func TestAIProxyConfigMapTemplate(t *testing.T) {
cm := AIProxyConfigMapTemplate("alice")
if cm.Name != "ai-proxy-config" {
t.Errorf("expected name ai-proxy-config, got %s", cm.Name)
}
if cm.Namespace != "dev-alice" {
t.Errorf("expected namespace dev-alice, got %s", cm.Namespace)
}
if _, ok := cm.Data["nginx.conf.template"]; !ok {
t.Error("missing nginx.conf.template in ConfigMap data")
}
if _, ok := cm.Data["entrypoint.sh"]; !ok {
t.Error("missing entrypoint.sh in ConfigMap data")
}
}
func TestPodTemplateWithDifferentUsers(t *testing.T) {
tests := []struct {
user string
pod string
wantNS string
wantPod string
wantPath string
}{
{"bob", "dev", "dev-bob", "dev-pod-dev", "/@bob/dev"},
{"team-alpha", "staging", "dev-team-alpha", "dev-pod-staging", "/@team-alpha/staging"},
}
for _, tt := range tests {
t.Run(tt.user+"/"+tt.pod, func(t *testing.T) {
opts := PodOpts{
User: tt.user, Pod: tt.pod,
CPUReq: "1", CPULimit: "2", MemReq: "1Gi", MemLimit: "2Gi",
}
pod := PodTemplate(testCfg, opts)
if pod.Namespace != tt.wantNS {
t.Errorf("expected namespace %s, got %s", tt.wantNS, pod.Namespace)
}
if pod.Name != tt.wantPod {
t.Errorf("expected pod name %s, got %s", tt.wantPod, pod.Name)
}
// Check TTYD_BASE_PATH
dev := pod.Spec.Containers[0]
for _, e := range dev.Env {
if e.Name == "TTYD_BASE_PATH" && e.Value != tt.wantPath {
t.Errorf("expected TTYD_BASE_PATH %s, got %s", tt.wantPath, e.Value)
}
}
})
}
}