Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- 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>
190 lines
5.5 KiB
Go
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
|
|
}
|