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