reconcileDelete asked Get first and dropped the finalizer only on not-found, so a permanent Get retried forever: a KVM host removed from the config, or a providerID the provider cannot resolve, left the Proxy in Terminating with no way out. It now tries Delete anyway, which is idempotent and can still finish where Get cannot converge, and releases the object when that succeeds. crawl.example.com/skip-cleanup abandons the instance for the cases that stay stuck, such as revoked credentials. It is checked before the provider lookup, so it also frees a Proxy whose provider is gone from the config. In the KVM provider, Delete over an unconfigured host is now a no-op success, mirroring removeVolumes over a missing pool. Get keeps failing permanently there on purpose: the live path reads not-found as "rebuild it", so a typo in hosts must not look like a missing VM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
618 lines
22 KiB
Go
618 lines
22 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
apimeta "k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
|
|
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
)
|
|
|
|
// Distinct per-interval values so an asserted ctrl.Result is unambiguous
|
|
// about which action-table row produced it.
|
|
const (
|
|
tProvisioningPoll = 11 * time.Second
|
|
tDriftPoll = 22 * time.Second
|
|
tDeletionPoll = 33 * time.Second
|
|
tQuotaRetry = 44 * time.Second
|
|
tRequeueNow = 55 * time.Millisecond
|
|
)
|
|
|
|
const (
|
|
testProxyName = "p1"
|
|
testNamespace = "default"
|
|
testUID = types.UID("11111111-2222-3333-4444-555555555555")
|
|
)
|
|
|
|
// stubProvider is the plan's in-test Provider stub: a handful of lines, no
|
|
// config format, no fault-injection surface beyond settable fields.
|
|
type stubProvider struct {
|
|
createID string
|
|
createErr error
|
|
getInst *provider.Instance
|
|
getErr error
|
|
deleteErr error
|
|
|
|
createCalls, getCalls, deleteCalls int
|
|
lastCreate provider.CreateRequest
|
|
}
|
|
|
|
func (s *stubProvider) Create(_ context.Context, req provider.CreateRequest) (string, error) {
|
|
s.createCalls++
|
|
s.lastCreate = req
|
|
if s.createErr != nil {
|
|
return "", s.createErr
|
|
}
|
|
return s.createID, nil
|
|
}
|
|
|
|
func (s *stubProvider) Get(_ context.Context, _ string) (*provider.Instance, error) {
|
|
s.getCalls++
|
|
if s.getErr != nil {
|
|
return nil, s.getErr
|
|
}
|
|
return s.getInst, nil
|
|
}
|
|
|
|
func (s *stubProvider) Delete(_ context.Context, _ string) error {
|
|
s.deleteCalls++
|
|
return s.deleteErr
|
|
}
|
|
|
|
func (s *stubProvider) ListByTag(context.Context) ([]provider.Instance, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func notFoundErr() error {
|
|
return provider.Wrap(provider.ErrNotFound, "get", "stub", "some-id", nil)
|
|
}
|
|
|
|
func permanentErr() error {
|
|
return provider.Wrap(provider.ErrPermanent, "get", "stub", "some-id", nil)
|
|
}
|
|
|
|
func testScheme(t *testing.T) *runtime.Scheme {
|
|
t.Helper()
|
|
s := runtime.NewScheme()
|
|
if err := crawlv1alpha1.AddToScheme(s); err != nil {
|
|
t.Fatalf("adding crawl scheme: %v", err)
|
|
}
|
|
if err := corev1.AddToScheme(s); err != nil {
|
|
t.Fatalf("adding core scheme: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// managedProxy returns a Managed proxy that already carries the finalizer —
|
|
// the state most action-table rows start from. Mutators adjust from there.
|
|
func managedProxy(mut ...func(*crawlv1alpha1.Proxy)) *crawlv1alpha1.Proxy {
|
|
p := &crawlv1alpha1.Proxy{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: testProxyName,
|
|
Namespace: testNamespace,
|
|
UID: testUID,
|
|
Generation: 1,
|
|
Finalizers: []string{crawlv1alpha1.FinalizerName},
|
|
},
|
|
Spec: crawlv1alpha1.ProxySpec{
|
|
Mode: crawlv1alpha1.ModeManaged,
|
|
Provider: "stub",
|
|
},
|
|
}
|
|
for _, m := range mut {
|
|
m(p)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func withProviderID(id string) func(*crawlv1alpha1.Proxy) {
|
|
return func(p *crawlv1alpha1.Proxy) { p.Status.ProviderID = id }
|
|
}
|
|
|
|
func withSpecHashAnnotation(hash string) func(*crawlv1alpha1.Proxy) {
|
|
return func(p *crawlv1alpha1.Proxy) {
|
|
if p.Annotations == nil {
|
|
p.Annotations = map[string]string{}
|
|
}
|
|
p.Annotations[crawlv1alpha1.AnnotationSpecHash] = hash
|
|
}
|
|
}
|
|
|
|
func withSkipCleanup() func(*crawlv1alpha1.Proxy) {
|
|
return func(p *crawlv1alpha1.Proxy) {
|
|
if p.Annotations == nil {
|
|
p.Annotations = map[string]string{}
|
|
}
|
|
p.Annotations[crawlv1alpha1.AnnotationSkipCleanup] = "true"
|
|
}
|
|
}
|
|
|
|
func deleting() func(*crawlv1alpha1.Proxy) {
|
|
return func(p *crawlv1alpha1.Proxy) {
|
|
now := metav1.Now()
|
|
p.DeletionTimestamp = &now
|
|
}
|
|
}
|
|
|
|
func newTestReconciler(t *testing.T, stub *stubProvider, objs ...client.Object) *ProxyReconciler {
|
|
t.Helper()
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(testScheme(t)).
|
|
WithStatusSubresource(&crawlv1alpha1.Proxy{}).
|
|
WithObjects(objs...).
|
|
Build()
|
|
return &ProxyReconciler{
|
|
Client: c,
|
|
Providers: map[string]provider.Provider{"stub": stub},
|
|
ProvisioningPoll: tProvisioningPoll,
|
|
DriftPoll: tDriftPoll,
|
|
DeletionPoll: tDeletionPoll,
|
|
QuotaRetry: tQuotaRetry,
|
|
RequeueNow: tRequeueNow,
|
|
}
|
|
}
|
|
|
|
func doReconcile(t *testing.T, r *ProxyReconciler) (ctrl.Result, error) {
|
|
t.Helper()
|
|
return r.Reconcile(context.Background(), ctrl.Request{
|
|
NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testProxyName},
|
|
})
|
|
}
|
|
|
|
func getProxy(t *testing.T, r *ProxyReconciler) *crawlv1alpha1.Proxy {
|
|
t.Helper()
|
|
var p crawlv1alpha1.Proxy
|
|
if err := r.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: testProxyName}, &p); err != nil {
|
|
t.Fatalf("getting proxy: %v", err)
|
|
}
|
|
return &p
|
|
}
|
|
|
|
func assertCondition(t *testing.T, p *crawlv1alpha1.Proxy, condType string, status metav1.ConditionStatus, reason string) {
|
|
t.Helper()
|
|
cond := apimeta.FindStatusCondition(p.Status.Conditions, condType)
|
|
if cond == nil {
|
|
t.Fatalf("condition %s missing, have %+v", condType, p.Status.Conditions)
|
|
}
|
|
if cond.Status != status || cond.Reason != reason {
|
|
t.Errorf("condition %s = %s/%s, want %s/%s", condType, cond.Status, cond.Reason, status, reason)
|
|
}
|
|
if cond.ObservedGeneration != p.Generation {
|
|
t.Errorf("condition %s observedGeneration = %d, want %d", condType, cond.ObservedGeneration, p.Generation)
|
|
}
|
|
}
|
|
|
|
// TestReconcile_actionTable exercises every row of the plan's action table
|
|
// by calling Reconcile directly against a fake client. Caveat (documented in
|
|
// the plan): the fake client runs neither CEL validation nor structural
|
|
// defaulting — the envtest suite covers those.
|
|
func TestReconcile_actionTable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The fixture's hash: no placement, no cloud-init, defaulted port.
|
|
freshHash := specHash(managedProxy(), "")
|
|
|
|
tests := []struct {
|
|
name string
|
|
proxy *crawlv1alpha1.Proxy
|
|
extraObjs []client.Object
|
|
stub *stubProvider
|
|
wantResult ctrl.Result
|
|
wantErr bool
|
|
verify func(t *testing.T, r *ProxyReconciler, stub *stubProvider)
|
|
}{
|
|
{
|
|
name: "managed without finalizer gets one and stops",
|
|
proxy: managedProxy(func(p *crawlv1alpha1.Proxy) {
|
|
p.Finalizers = nil
|
|
}),
|
|
stub: &stubProvider{},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if len(p.Finalizers) != 1 || p.Finalizers[0] != crawlv1alpha1.FinalizerName {
|
|
t.Errorf("finalizers = %v, want [%s]", p.Finalizers, crawlv1alpha1.FinalizerName)
|
|
}
|
|
if stub.createCalls+stub.getCalls+stub.deleteCalls != 0 {
|
|
t.Errorf("provider was called before the finalizer was persisted")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "empty providerID creates the instance",
|
|
proxy: managedProxy(),
|
|
stub: &stubProvider{createID: "stub-id-1"},
|
|
wantResult: ctrl.Result{RequeueAfter: tProvisioningPoll},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if p.Status.ProviderID != "stub-id-1" {
|
|
t.Errorf("providerID = %q, want stub-id-1", p.Status.ProviderID)
|
|
}
|
|
if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != freshHash {
|
|
t.Errorf("spec-hash annotation = %q, want %q", got, freshHash)
|
|
}
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonProvisioning)
|
|
if p.Status.Phase != crawlv1alpha1.PhaseProvisioning {
|
|
t.Errorf("phase = %s, want Provisioning", p.Status.Phase)
|
|
}
|
|
want := provider.CreateRequest{
|
|
Name: provider.NameFromUID(testUID),
|
|
UID: string(testUID),
|
|
Namespace: testNamespace,
|
|
ProxyName: testProxyName,
|
|
Port: crawlv1alpha1.DefaultPort,
|
|
}
|
|
if stub.lastCreate != want {
|
|
t.Errorf("CreateRequest = %+v, want %+v", stub.lastCreate, want)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "provisioning instance polls again",
|
|
proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)),
|
|
stub: &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateProvisioning}},
|
|
wantResult: ctrl.Result{RequeueAfter: tProvisioningPoll},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if p.Status.IP != "" {
|
|
t.Errorf("ip = %q, want empty while provisioning", p.Status.IP)
|
|
}
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonProvisioning)
|
|
},
|
|
},
|
|
{
|
|
name: "running instance publishes IP",
|
|
proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)),
|
|
stub: &stubProvider{
|
|
getInst: &provider.Instance{ID: "stub-id-1", IP: "10.1.2.3", State: provider.StateRunning},
|
|
},
|
|
wantResult: ctrl.Result{RequeueAfter: tDriftPoll},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if p.Status.IP != "10.1.2.3" {
|
|
t.Errorf("ip = %q, want 10.1.2.3", p.Status.IP)
|
|
}
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionTrue, ReasonCreated)
|
|
if p.Status.Phase != crawlv1alpha1.PhaseProvisioning {
|
|
t.Errorf("phase = %s, want Provisioning until a health verdict exists", p.Status.Phase)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "stopped instance is deleted for recreation",
|
|
proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash)),
|
|
stub: &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateStopped}},
|
|
wantResult: ctrl.Result{RequeueAfter: tDeletionPoll},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
if stub.deleteCalls != 1 {
|
|
t.Errorf("deleteCalls = %d, want 1", stub.deleteCalls)
|
|
}
|
|
assertCondition(t, getProxy(t, r), crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonRecreating)
|
|
},
|
|
},
|
|
{
|
|
name: "vanished instance clears ID for recreation",
|
|
proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation(freshHash),
|
|
func(p *crawlv1alpha1.Proxy) { p.Status.IP = "10.1.2.3" }),
|
|
stub: &stubProvider{getErr: notFoundErr()},
|
|
wantResult: ctrl.Result{RequeueAfter: tRequeueNow},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if p.Status.ProviderID != "" || p.Status.IP != "" {
|
|
t.Errorf("providerID/ip = %q/%q, want both cleared", p.Status.ProviderID, p.Status.IP)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "hash mismatch deletes the old instance",
|
|
proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation("stale-hash")),
|
|
stub: &stubProvider{
|
|
getInst: &provider.Instance{ID: "stub-id-1", IP: "10.1.2.3", State: provider.StateRunning},
|
|
},
|
|
wantResult: ctrl.Result{RequeueAfter: tDeletionPoll},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
if stub.deleteCalls != 1 {
|
|
t.Errorf("deleteCalls = %d, want 1", stub.deleteCalls)
|
|
}
|
|
p := getProxy(t, r)
|
|
// The hash must not advance until the old instance is gone,
|
|
// or a crash would strand a half-replaced proxy.
|
|
if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != "stale-hash" {
|
|
t.Errorf("spec-hash annotation = %q, want still stale-hash", got)
|
|
}
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonReplacing)
|
|
},
|
|
},
|
|
{
|
|
name: "empty annotation adopts instead of replacing",
|
|
proxy: managedProxy(withProviderID("stub-id-1")),
|
|
stub: &stubProvider{},
|
|
wantResult: ctrl.Result{RequeueAfter: tRequeueNow},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != freshHash {
|
|
t.Errorf("spec-hash annotation = %q, want %q", got, freshHash)
|
|
}
|
|
if p.Status.ProviderID != "stub-id-1" {
|
|
t.Errorf("providerID = %q, want untouched stub-id-1", p.Status.ProviderID)
|
|
}
|
|
if stub.deleteCalls != 0 {
|
|
t.Errorf("deleteCalls = %d, want 0 — adoption must not replace", stub.deleteCalls)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "mismatch with instance gone advances the hash",
|
|
proxy: managedProxy(withProviderID("stub-id-1"), withSpecHashAnnotation("stale-hash")),
|
|
stub: &stubProvider{getErr: notFoundErr()},
|
|
wantResult: ctrl.Result{RequeueAfter: tRequeueNow},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if p.Status.ProviderID != "" {
|
|
t.Errorf("providerID = %q, want cleared", p.Status.ProviderID)
|
|
}
|
|
if got := p.Annotations[crawlv1alpha1.AnnotationSpecHash]; got != freshHash {
|
|
t.Errorf("spec-hash annotation = %q, want advanced to %q", got, freshHash)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "deletion with no providerID removes the finalizer",
|
|
proxy: managedProxy(deleting()),
|
|
stub: &stubProvider{},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
assertProxyGone(t, r)
|
|
if stub.deleteCalls != 0 {
|
|
t.Errorf("deleteCalls = %d, want 0", stub.deleteCalls)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "deletion with instance already gone removes the finalizer",
|
|
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
|
|
stub: &stubProvider{getErr: notFoundErr()},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
assertProxyGone(t, r)
|
|
},
|
|
},
|
|
{
|
|
// A permanent Get (a retired KVM host, a providerID the provider
|
|
// cannot resolve) used to retry forever with the finalizer on.
|
|
name: "deletion with an unreachable instance still deletes and releases",
|
|
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
|
|
stub: &stubProvider{getErr: permanentErr()},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
assertProxyGone(t, r)
|
|
if stub.deleteCalls != 1 {
|
|
t.Errorf("deleteCalls = %d, want 1 — Delete must be tried when Get cannot answer", stub.deleteCalls)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "deletion keeps the finalizer when cleanup itself fails",
|
|
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
|
|
stub: &stubProvider{
|
|
getErr: permanentErr(),
|
|
deleteErr: provider.Wrap(provider.ErrPermanent, "delete", "stub", "stub-id-1", nil),
|
|
},
|
|
wantResult: ctrl.Result{},
|
|
wantErr: true,
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
// getProxy fails the test if the object is gone, which is what
|
|
// finalizer removal would have caused.
|
|
if p := getProxy(t, r); !slices.Contains(p.Finalizers, crawlv1alpha1.FinalizerName) {
|
|
t.Error("finalizer removed although cleanup failed; the instance would leak silently")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skip-cleanup annotation abandons the instance",
|
|
proxy: managedProxy(deleting(), withProviderID("stub-id-1"), withSkipCleanup()),
|
|
stub: &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateRunning}},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
assertProxyGone(t, r)
|
|
if stub.getCalls != 0 || stub.deleteCalls != 0 {
|
|
t.Errorf("getCalls/deleteCalls = %d/%d, want 0/0 — the provider must not be asked", stub.getCalls, stub.deleteCalls)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "skip-cleanup annotation releases a proxy whose provider is gone",
|
|
proxy: managedProxy(deleting(), withProviderID("stub-id-1"), withSkipCleanup(),
|
|
func(p *crawlv1alpha1.Proxy) { p.Spec.Provider = "retired" }),
|
|
stub: &stubProvider{},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
assertProxyGone(t, r)
|
|
},
|
|
},
|
|
{
|
|
name: "deletion deletes the instance and polls",
|
|
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
|
|
stub: &stubProvider{
|
|
getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateRunning},
|
|
},
|
|
wantResult: ctrl.Result{RequeueAfter: tDeletionPoll},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
if stub.deleteCalls != 1 {
|
|
t.Errorf("deleteCalls = %d, want 1", stub.deleteCalls)
|
|
}
|
|
p := getProxy(t, r)
|
|
if p.Status.Phase != crawlv1alpha1.PhaseDeleting {
|
|
t.Errorf("phase = %s, want Deleting", p.Status.Phase)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "external proxy tracks its endpoint without a finalizer",
|
|
proxy: managedProxy(func(p *crawlv1alpha1.Proxy) {
|
|
p.Finalizers = nil
|
|
p.Spec = crawlv1alpha1.ProxySpec{
|
|
Mode: crawlv1alpha1.ModeExternal,
|
|
Endpoint: &crawlv1alpha1.EndpointSpec{Host: "203.0.113.7", Port: 8080},
|
|
}
|
|
}),
|
|
stub: &stubProvider{},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if p.Status.IP != "203.0.113.7" {
|
|
t.Errorf("ip = %q, want the endpoint host", p.Status.IP)
|
|
}
|
|
if len(p.Finalizers) != 0 {
|
|
t.Errorf("finalizers = %v, want none on External", p.Finalizers)
|
|
}
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionTrue, ReasonExternalEndpoint)
|
|
if stub.createCalls+stub.getCalls+stub.deleteCalls != 0 {
|
|
t.Errorf("provider was called for an External proxy")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "quota error backs off slowly without failing",
|
|
proxy: managedProxy(),
|
|
stub: &stubProvider{
|
|
createErr: provider.Wrap(provider.ErrQuotaExceeded, "create", "stub", "", nil),
|
|
},
|
|
wantResult: ctrl.Result{RequeueAfter: tQuotaRetry},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
p := getProxy(t, r)
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonQuotaExceeded)
|
|
if p.Status.Phase == crawlv1alpha1.PhaseFailed {
|
|
t.Errorf("phase = Failed, want anything but — quota is a wait, not a failure")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "permanent error latches Failed and stops calling the provider",
|
|
proxy: managedProxy(),
|
|
stub: &stubProvider{
|
|
createErr: provider.Wrap(provider.ErrPermanent, "create", "stub", "", nil),
|
|
},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
p := getProxy(t, r)
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonPermanentError)
|
|
if p.Status.Phase != crawlv1alpha1.PhaseFailed {
|
|
t.Errorf("phase = %s, want Failed", p.Status.Phase)
|
|
}
|
|
if res, err := doReconcile(t, r); err != nil || res != (ctrl.Result{}) {
|
|
t.Errorf("second reconcile = %+v, %v; want empty result, nil", res, err)
|
|
}
|
|
if stub.createCalls != 1 {
|
|
t.Errorf("createCalls = %d after latch, want 1", stub.createCalls)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "transient error is returned for workqueue backoff",
|
|
proxy: managedProxy(),
|
|
stub: &stubProvider{
|
|
createErr: provider.Wrap(provider.ErrTransient, "create", "stub", "", nil),
|
|
},
|
|
wantResult: ctrl.Result{},
|
|
wantErr: true,
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
p := getProxy(t, r)
|
|
if p.Status.Phase != crawlv1alpha1.PhasePending {
|
|
t.Errorf("phase = %s, want still Pending", p.Status.Phase)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "unconfigured provider is a permanent failure",
|
|
proxy: managedProxy(func(p *crawlv1alpha1.Proxy) {
|
|
p.Spec.Provider = "no-such-provider"
|
|
}),
|
|
stub: &stubProvider{},
|
|
wantResult: ctrl.Result{},
|
|
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
|
|
p := getProxy(t, r)
|
|
assertCondition(t, p, crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonPermanentError)
|
|
if p.Status.Phase != crawlv1alpha1.PhaseFailed {
|
|
t.Errorf("phase = %s, want Failed", p.Status.Phase)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "cloud-init secret is resolved into the create request",
|
|
proxy: managedProxy(func(p *crawlv1alpha1.Proxy) {
|
|
p.Spec.CloudInit = &crawlv1alpha1.CloudInitSpec{
|
|
SecretRef: &crawlv1alpha1.SecretKeySelector{Name: "ci-secret"},
|
|
}
|
|
}),
|
|
extraObjs: []client.Object{
|
|
&corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "ci-secret", Namespace: testNamespace},
|
|
Data: map[string][]byte{"user-data": []byte("#cloud-config\npackages: [squid]")},
|
|
},
|
|
},
|
|
stub: &stubProvider{createID: "stub-id-1"},
|
|
wantResult: ctrl.Result{RequeueAfter: tProvisioningPoll},
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
if want := "#cloud-config\npackages: [squid]"; stub.lastCreate.CloudInit != want {
|
|
t.Errorf("CreateRequest.CloudInit = %q, want the resolved secret content", stub.lastCreate.CloudInit)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "missing cloud-init secret errors and marks the condition",
|
|
proxy: managedProxy(func(p *crawlv1alpha1.Proxy) {
|
|
p.Spec.CloudInit = &crawlv1alpha1.CloudInitSpec{
|
|
SecretRef: &crawlv1alpha1.SecretKeySelector{Name: "absent"},
|
|
}
|
|
}),
|
|
stub: &stubProvider{},
|
|
wantResult: ctrl.Result{},
|
|
wantErr: true,
|
|
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
|
|
assertCondition(t, getProxy(t, r), crawlv1alpha1.ConditionProvisioned, metav1.ConditionFalse, ReasonCloudInitError)
|
|
if stub.createCalls != 0 {
|
|
t.Errorf("createCalls = %d, want 0 with unresolved cloud-init", stub.createCalls)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
objs := append([]client.Object{tc.proxy}, tc.extraObjs...)
|
|
r := newTestReconciler(t, tc.stub, objs...)
|
|
res, err := doReconcile(t, r)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("Reconcile error = %v, wantErr %v", err, tc.wantErr)
|
|
}
|
|
if res != tc.wantResult {
|
|
t.Errorf("Result = %+v, want %+v", res, tc.wantResult)
|
|
}
|
|
tc.verify(t, r, tc.stub)
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertProxyGone(t *testing.T, r *ProxyReconciler) {
|
|
t.Helper()
|
|
var p crawlv1alpha1.Proxy
|
|
err := r.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: testProxyName}, &p)
|
|
if !apierrors.IsNotFound(err) {
|
|
t.Errorf("proxy still exists (err=%v), want NotFound after finalizer removal", err)
|
|
}
|
|
}
|