Add the Kubernetes pod provider (replaces the removed mock)
Create/Get/Delete/ListByTag against real corev1.Pod objects in the same cluster the operator runs in, running an ubuntu/squid container -- picked by actually checking Docker Hub metadata (Canonical-published, rebuilt the same day this was decided, 50M+ pulls) rather than guessing an image reference. It's a public image, so kind nodes pull it directly with no build/load step. providerID is "<namespace>/<podName>", parsed via cache.SplitMetaNamespaceKey -- the same self-contained-providerID reasoning the plan already calls for on the GCP provider's zone-qualified IDs. Pod state maps to InstanceState with Succeeded/Failed/Unknown all collapsing to Terminated, since the reconciler already treats Stopped and Terminated identically; Running-without-PodIP maps to Provisioning so an empty IP is never published. The client is built internally via ctrl.GetConfig() (in-cluster or local kubeconfig, whichever applies), not threaded through the registry Constructor signature -- this is what lets `make run` against a local kind cluster and running in-cluster share the exact same code path with no provider-specific wiring in cmd/main.go. New() is deliberately untested (0% coverage): it's the one function that must never run under `go test`, since it would happily connect to whatever cluster the developer's kubeconfig points at. Tests construct Provider via an unexported newWithClient(client, cfg) instead. ListByTag lists Pods across every namespace (orphan GC needs to find every tagged Pod regardless of where it landed), which means this provider's RBAC has to be a ClusterRole rather than namespace-scoped -- flagged now, wired in Step 10. provider.Config gains KubernetesConfig (replacing MockConfig) and drops the FailWith*/fault-injection surface entirely, since that need is now served by a small in-test stub Provider for reconciler tests (Step 4), not a config-driven mechanism on a real provider package. Tests use sigs.k8s.io/controller-runtime/pkg/client/fake -- real Pod objects, the real client.Client interface -- at 77.6% coverage. make test green across the whole repo. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
245
internal/provider/kubernetes/kubernetes_test.go
Normal file
245
internal/provider/kubernetes/kubernetes_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||
)
|
||||
|
||||
func newTestProvider(objs ...runtime.Object) *Provider {
|
||||
scheme := runtime.NewScheme()
|
||||
if err := corev1.AddToScheme(scheme); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build()
|
||||
return newWithClient(c, provider.ProviderConfig{Name: "kubernetes"})
|
||||
}
|
||||
|
||||
func testPod(namespace, name, uid string, phase corev1.PodPhase, ip string) *corev1.Pod {
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Labels: map[string]string{
|
||||
provider.LabelManaged: provider.LabelManagedYes,
|
||||
provider.LabelUID: uid,
|
||||
},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: phase, PodIP: ip},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Create_buildsPodAndReturnsProviderID(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider()
|
||||
id, err := p.Create(context.Background(), provider.CreateRequest{
|
||||
Name: "proxy-abc", UID: "uid-1", Namespace: "crawl", Port: 3128,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if id != "crawl/proxy-abc" {
|
||||
t.Errorf("Create() id = %q, want %q", id, "crawl/proxy-abc")
|
||||
}
|
||||
|
||||
var pod corev1.Pod
|
||||
key := types.NamespacedName{Namespace: "crawl", Name: "proxy-abc"}
|
||||
if err := p.client.Get(context.Background(), key, &pod); err != nil {
|
||||
t.Fatalf("expected the Pod to exist: %v", err)
|
||||
}
|
||||
if pod.Labels[provider.LabelUID] != "uid-1" {
|
||||
t.Errorf("pod UID label = %q, want %q", pod.Labels[provider.LabelUID], "uid-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Create_idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider()
|
||||
ctx := context.Background()
|
||||
req := provider.CreateRequest{Name: "proxy-dup", UID: "uid-2", Namespace: "crawl", Port: 3128}
|
||||
|
||||
id1, err := p.Create(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() #1 error = %v", err)
|
||||
}
|
||||
id2, err := p.Create(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() #2 error = %v, want nil (AlreadyExists must be absorbed)", err)
|
||||
}
|
||||
if id1 != id2 {
|
||||
t.Errorf("Create() not idempotent: %q != %q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Get_stateMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
phase corev1.PodPhase
|
||||
ip string
|
||||
wantState provider.InstanceState
|
||||
wantIP string
|
||||
}{
|
||||
{"pending", corev1.PodPending, "", provider.StateProvisioning, ""},
|
||||
{"running with IP", corev1.PodRunning, "10.0.0.5", provider.StateRunning, "10.0.0.5"},
|
||||
{"running without IP yet", corev1.PodRunning, "", provider.StateProvisioning, ""},
|
||||
{"succeeded", corev1.PodSucceeded, "10.0.0.5", provider.StateTerminated, ""},
|
||||
{"failed", corev1.PodFailed, "10.0.0.5", provider.StateTerminated, ""},
|
||||
{"unknown", corev1.PodUnknown, "10.0.0.5", provider.StateTerminated, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
pod := testPod("crawl", "proxy-"+tc.name, "uid", tc.phase, tc.ip)
|
||||
p := newTestProvider(pod)
|
||||
inst, err := p.Get(context.Background(), "crawl/"+pod.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if inst.State != tc.wantState {
|
||||
t.Errorf("State = %v, want %v", inst.State, tc.wantState)
|
||||
}
|
||||
if inst.IP != tc.wantIP {
|
||||
t.Errorf("IP = %q, want %q", inst.IP, tc.wantIP)
|
||||
}
|
||||
if inst.UID != "uid" {
|
||||
t.Errorf("UID = %q, want %q", inst.UID, "uid")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Get_notFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider()
|
||||
_, err := p.Get(context.Background(), "crawl/does-not-exist")
|
||||
if !errors.Is(err, provider.ErrNotFound) {
|
||||
t.Errorf("Get() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Get_malformedProviderID(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider()
|
||||
_, err := p.Get(context.Background(), "no-namespace-here")
|
||||
if err == nil {
|
||||
t.Fatal("Get() error = nil, want error for a providerID with no namespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Delete_idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
pod := testPod("crawl", "proxy-del", "uid", corev1.PodRunning, "10.0.0.5")
|
||||
p := newTestProvider(pod)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := p.Delete(ctx, "crawl/proxy-del"); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if err := p.Delete(ctx, "crawl/proxy-del"); err != nil {
|
||||
t.Errorf("Delete() (repeat) error = %v, want nil", err)
|
||||
}
|
||||
if err := p.Delete(ctx, "crawl/never-existed"); err != nil {
|
||||
t.Errorf("Delete() on unknown ID error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if _, err := p.Get(ctx, "crawl/proxy-del"); !errors.Is(err, provider.ErrNotFound) {
|
||||
t.Errorf("Get() after Delete() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Delete_malformedProviderID(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider()
|
||||
if err := p.Delete(context.Background(), "no-namespace-here"); err == nil {
|
||||
t.Fatal("Delete() error = nil, want error for a providerID with no namespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ListByTag_filtersByLabelAcrossNamespaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
managed1 := testPod("crawl", "proxy-a", "uid-a", corev1.PodRunning, "10.0.0.1")
|
||||
managed2 := testPod("other-ns", "proxy-b", "uid-b", corev1.PodPending, "")
|
||||
unmanaged := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "not-ours", Namespace: "crawl"}}
|
||||
|
||||
p := newTestProvider(managed1, managed2, unmanaged)
|
||||
instances, err := p.ListByTag(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTag() error = %v", err)
|
||||
}
|
||||
if len(instances) != 2 {
|
||||
t.Fatalf("len(instances) = %d, want 2 (unmanaged Pod must be excluded)", len(instances))
|
||||
}
|
||||
|
||||
byID := make(map[string]provider.Instance, len(instances))
|
||||
for _, inst := range instances {
|
||||
byID[inst.ID] = inst
|
||||
}
|
||||
a, ok := byID["crawl/proxy-a"]
|
||||
if !ok {
|
||||
t.Fatal(`instances missing "crawl/proxy-a"`)
|
||||
}
|
||||
if a.State != provider.StateRunning || a.IP != "10.0.0.1" {
|
||||
t.Errorf("crawl/proxy-a = %+v, want Running/10.0.0.1", a)
|
||||
}
|
||||
if _, ok := byID["other-ns/proxy-b"]; !ok {
|
||||
t.Fatal(`instances missing "other-ns/proxy-b" (ListByTag must not be namespace-scoped)`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithClient_image(t *testing.T) {
|
||||
t.Parallel()
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
c := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||
|
||||
def := newWithClient(c, provider.ProviderConfig{Name: "k8s"})
|
||||
if def.image != defaultImage {
|
||||
t.Errorf("default image = %q, want %q", def.image, defaultImage)
|
||||
}
|
||||
|
||||
custom := newWithClient(c, provider.ProviderConfig{
|
||||
Name: "k8s",
|
||||
Kubernetes: &provider.KubernetesConfig{Image: "myregistry/squid:custom"},
|
||||
})
|
||||
if custom.image != "myregistry/squid:custom" {
|
||||
t.Errorf("custom image = %q, want %q", custom.image, "myregistry/squid:custom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
t.Parallel()
|
||||
gr := schema.GroupResource{Group: "", Resource: "pods"}
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want error
|
||||
}{
|
||||
{"not found", apierrors.NewNotFound(gr, "x"), provider.ErrNotFound},
|
||||
{"too many requests", apierrors.NewTooManyRequests("slow down", 5), provider.ErrTransient},
|
||||
{"server timeout", apierrors.NewServerTimeout(gr, "create", 5), provider.ErrTransient},
|
||||
{"forbidden", apierrors.NewForbidden(gr, "x", errors.New("denied")), provider.ErrPermanent},
|
||||
{"bad request", apierrors.NewBadRequest("bad"), provider.ErrPermanent},
|
||||
{"unauthorized", apierrors.NewUnauthorized("no creds"), provider.ErrPermanent},
|
||||
{"unclassified", errors.New("boom"), provider.ErrTransient},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := classify("op", "id", tc.err)
|
||||
if !errors.Is(got, tc.want) {
|
||||
t.Errorf("classify(%v) = %v, want class %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user