rdev/internal/worker/resource_gc_test.go
jordan 9226454b85
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: label-based undeploy, GC reconciliation, checkout/sessions, pool status
- Add UndeployAll() using label selectors to clean up monorepo components
  on project deletion (replaces name-based Undeploy in DeleteProject and
  the direct undeploy handler)
- Add ResourceGC background worker that periodically finds K8s resources
  whose project label has no matching DB record, deletes after 1h safety
  window
- Widen deployer client type from *kubernetes.Clientset to
  kubernetes.Interface for testability
- UndeployAll accumulates errors via errors.Join instead of failing fast
- Add checkout/checkin sidecar dev flow: temporary git tokens, branch
  checkout, review on checkin with cleanup workers
- Add interactive sessions: pod binding, command execution, SSE streaming,
  ephemeral preview URLs with session cleanup workers
- Add GET /workers/pool endpoint for aggregate capacity and queue depth
- Add sessions:read and sessions:execute auth scopes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:11:28 -07:00

190 lines
5.5 KiB
Go

package worker
import (
"context"
"testing"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
"github.com/orchard9/rdev/internal/adapter/deployer"
)
// mockProjectChecker simulates DB project existence checks.
type mockProjectChecker struct {
projects map[string]bool
}
func (m *mockProjectChecker) projectExists(_ context.Context, projectID string) (bool, error) {
return m.projects[projectID], nil
}
func newTestDeployerForGC(client *fake.Clientset) *deployer.Deployer {
return deployer.NewDeployer(client, deployer.Config{Namespace: "projects"})
}
func createDeployment(t *testing.T, client *fake.Clientset, name, project string, createdAt time.Time) {
t.Helper()
replicas := int32(1)
_, err := client.AppsV1().Deployments("projects").Create(context.Background(), &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "projects",
Labels: map[string]string{"app": name, "project": project},
CreationTimestamp: metav1.NewTime(createdAt),
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: name, Image: "test:latest"}}},
},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("failed to create deployment %s: %v", name, err)
}
}
func TestResourceGC_DeletesOrphans(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
// Create resources for two projects, both old enough
oldTime := time.Now().Add(-2 * time.Hour)
createDeployment(t, client, "orphan-proj", "orphan-proj", oldTime)
createDeployment(t, client, "valid-proj", "valid-proj", oldTime)
checker := &mockProjectChecker{
projects: map[string]bool{
"valid-proj": true, // exists in DB
// orphan-proj is NOT in DB
},
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour, // won't tick during test
})
gc.runReconciliation()
// Orphan should be deleted
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 1 {
t.Fatalf("expected 1 deployment, got %d", len(deps.Items))
}
if deps.Items[0].Name != "valid-proj" {
t.Errorf("remaining deployment = %q, want %q", deps.Items[0].Name, "valid-proj")
}
}
func TestResourceGC_SkipsValidProjects(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
oldTime := time.Now().Add(-2 * time.Hour)
createDeployment(t, client, "proj-a", "proj-a", oldTime)
createDeployment(t, client, "proj-b", "proj-b", oldTime)
checker := &mockProjectChecker{
projects: map[string]bool{
"proj-a": true,
"proj-b": true,
},
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour,
})
gc.runReconciliation()
// Both should remain
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 2 {
t.Errorf("expected 2 deployments, got %d", len(deps.Items))
}
}
func TestResourceGC_SkipsYoungOrphans(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
// Create a recent deployment (younger than minAge)
recentTime := time.Now().Add(-5 * time.Minute)
createDeployment(t, client, "new-orphan", "new-orphan", recentTime)
checker := &mockProjectChecker{
projects: map[string]bool{}, // not in DB
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour,
})
gc.runReconciliation()
// Should NOT be deleted (too young)
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 1 {
t.Errorf("expected 1 deployment (young orphan preserved), got %d", len(deps.Items))
}
}
func TestResourceGC_DeletesMonorepoComponents(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
oldTime := time.Now().Add(-2 * time.Hour)
createDeployment(t, client, "myapp", "myapp", oldTime)
createDeployment(t, client, "myapp-studio-ui", "myapp", oldTime)
createDeployment(t, client, "myapp-studio-api", "myapp", oldTime)
checker := &mockProjectChecker{
projects: map[string]bool{}, // not in DB
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour,
})
gc.runReconciliation()
// All 3 should be deleted
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 0 {
t.Errorf("expected 0 deployments, got %d", len(deps.Items))
}
}
func TestResourceGC_StartStop(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
checker := &mockProjectChecker{projects: map[string]bool{}}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 24 * time.Hour, // long interval so ticker won't fire
})
gc.Start()
// Give the goroutine time to run initial reconciliation
time.Sleep(50 * time.Millisecond)
gc.Stop() // should not hang
}