Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ go build ./...
go test ./...
```

The test suite covers 108 unit tests across 4 packages using only the standard
`testing` package and `controller-runtime`'s fake client — no external cluster
required.
The test suite covers 168 unit tests across 4 packages (93% statement coverage
in `internal/`) using only the standard `testing` package and
`controller-runtime`'s fake client — no external cluster required.

### Lint and format

Expand Down Expand Up @@ -163,7 +163,7 @@ CI uses `nix-build` for reproducible builds. The `tests` derivation runs
external toolchain needed:

```sh
# CI check: go fmt + go vet + 108 unit tests
# CI check: go fmt + go vet + 168 unit tests
nix-build nix -A tests

# Build the manager binary only
Expand Down
291 changes: 288 additions & 3 deletions ROADMAP.md

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions internal/controller/internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controller

import "testing"

func TestOtherFinalizer(t *testing.T) {
cases := []struct {
name string
finalizers []string
skip string
want string
}{
{"multiple, one to skip", []string{"a.finalizer", Finalizer, "b.finalizer"}, Finalizer, "a.finalizer"},
{"empty list", nil, Finalizer, ""},
{"single matching (skip target only)", []string{Finalizer}, Finalizer, ""},
{"single non-matching", []string{"other.finalizer"}, Finalizer, "other.finalizer"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := otherFinalizer(c.finalizers, c.skip)
if got != c.want {
t.Errorf("otherFinalizer(%v, %q) = %q, want %q", c.finalizers, c.skip, got, c.want)
}
})
}
}
264 changes: 264 additions & 0 deletions internal/controller/openstackcluster_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"

Expand All @@ -17,6 +18,7 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

"github.com/azimuth-cloud/cluster-api-janitor-openstack/internal/controller"
Expand Down Expand Up @@ -48,6 +50,25 @@ func newReconciler(purgeFunc func(context.Context, openstack.PurgeOptions) error
return r, c
}

func newReconcilerWithInterceptors(
purgeFunc func(context.Context, openstack.PurgeOptions) error,
interceptors interceptor.Funcs,
objs ...client.Object,
) (*controller.OpenStackClusterReconciler, client.Client) {
c := fake.NewClientBuilder().
WithScheme(testScheme).
WithObjects(objs...).
WithInterceptorFuncs(interceptors).
Build()
r := &controller.OpenStackClusterReconciler{
Client: c,
Scheme: testScheme,
PurgeFunc: purgeFunc,
SleepFunc: func(time.Duration) {},
}
return r, c
}

func newCluster(name, namespace string, opts ...func(*infrav1.OpenStackCluster)) *infrav1.OpenStackCluster {
c := &infrav1.OpenStackCluster{
ObjectMeta: metav1.ObjectMeta{
Expand Down Expand Up @@ -283,3 +304,246 @@ func TestReconcile_IgnoresNotFound_WhenClusterDeletedDuringRetry(t *testing.T) {
t.Fatalf("expected no error when cluster deleted during retry annotation, got: %v", err)
}
}

// ── Additional error-path and branch coverage ────────────────────────────────

// Scenario: fetching the cluster fails with a non-NotFound error → propagated
func TestReconcile_GetError_NonNotFound_Propagates(t *testing.T) {
r, _ := newReconcilerWithInterceptors(nil, interceptor.Funcs{
Get: func(ctx context.Context, c client.WithWatch, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error {
return errors.New("boom")
},
})

if _, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default")); err == nil {
t.Fatal("expected error to be propagated, got nil")
}
}

// Scenario: adding the finalizer fails on Update → propagated
func TestReconcile_AddFinalizer_UpdateError_Propagates(t *testing.T) {
cluster := newCluster("mycluster", "default")
r, _ := newReconcilerWithInterceptors(nil, interceptor.Funcs{
Update: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.UpdateOption) error {
return errors.New("update failed")
},
}, cluster)

_, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default"))
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "adding finalizer:") {
t.Errorf("expected error to wrap %q, got: %v", "adding finalizer:", err)
}
}

// Scenario: fetching the identity secret fails with a non-NotFound error → propagated
func TestReconcile_GetSecret_NonNotFoundError_Propagates(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
r, _ := newReconcilerWithInterceptors(nil, interceptor.Funcs{
Get: func(ctx context.Context, c client.WithWatch, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*corev1.Secret); ok {
return errors.New("secret get failed")
}
return c.Get(ctx, key, obj, opts...)
},
}, cluster)

_, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default"))
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "fetching identity secret:") {
t.Errorf("expected error to wrap %q, got: %v", "fetching identity secret:", err)
}
}

// Scenario: identity secret does not exist → Reconcile returns early without error
func TestReconcile_SecretNotFound_ReturnsEarlyWithoutError(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
purgeCalled := false
r, _ := newReconciler(func(context.Context, openstack.PurgeOptions) error {
purgeCalled = true
return nil
}, cluster) // no secret created

res, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res != (ctrl.Result{}) {
t.Errorf("expected empty result, got: %v", res)
}
if purgeCalled {
t.Error("expected purge NOT to be called when identity secret is absent")
}
}

// Scenario: IdentityRef.CloudName empty → defaults to "openstack"
func TestReconcile_CloudName_DefaultsToOpenstack_WhenEmpty(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp,
func(c *infrav1.OpenStackCluster) { c.Spec.IdentityRef.CloudName = "" })
secret := newSecret("cloud-credentials", "default")

var capturedCloudName string
r, _ := newReconciler(func(_ context.Context, opts openstack.PurgeOptions) error {
capturedCloudName = opts.CloudName
return nil
}, cluster, secret)

if _, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default")); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedCloudName != "openstack" {
t.Errorf("expected CloudName to default to %q, got %q", "openstack", capturedCloudName)
}
}

// Scenario: purge fails and the subsequent retry-annotation Patch also fails
// with a non-NotFound error → propagated
func TestReconcile_AnnotateRetry_NonNotFoundError_Propagates(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
secret := newSecret("cloud-credentials", "default")

r, _ := newReconcilerWithInterceptors(
func(context.Context, openstack.PurgeOptions) error { return errors.New("purge failed") },
interceptor.Funcs{
Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
return errors.New("patch failed")
},
},
cluster, secret,
)

_, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default"))
if err == nil {
t.Fatal("expected error to be propagated, got nil")
}
}

// Scenario: credential policy "delete" and this is the last finalizer →
// secret deleted and janitor finalizer removed
func TestReconcile_CredentialPolicyDelete_LastFinalizer_DeletesSecretAndRemovesFinalizer(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
secret := newSecret("cloud-credentials", "default")
secret.Annotations = map[string]string{controller.CredentialPolicyAnnotation: controller.PolicyDelete}

r, c := newReconciler(func(context.Context, openstack.PurgeOptions) error { return nil }, cluster, secret)

if _, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default")); err != nil {
t.Fatalf("unexpected error: %v", err)
}

var gotSecret corev1.Secret
err := c.Get(context.Background(), types.NamespacedName{Name: "cloud-credentials", Namespace: "default"}, &gotSecret)
if !apierrors.IsNotFound(err) {
t.Errorf("expected credential secret to be deleted, got err: %v", err)
}

got := getClusterOrNil(t, c, "mycluster", "default")
if got != nil && controllerutil.ContainsFinalizer(got, controller.Finalizer) {
t.Error("expected janitor finalizer to be removed")
}
}

// Scenario: credential policy "delete" but other finalizers remain → secret
// kept, retry annotation set, janitor finalizer NOT removed
func TestReconcile_CredentialPolicyDelete_OtherFinalizersPresent_SecretKeptRetryAnnotated(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
controllerutil.AddFinalizer(cluster, "other.finalizer.example.com")
secret := newSecret("cloud-credentials", "default")
secret.Annotations = map[string]string{controller.CredentialPolicyAnnotation: controller.PolicyDelete}

r, c := newReconciler(func(context.Context, openstack.PurgeOptions) error { return nil }, cluster, secret)

if _, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default")); err != nil {
t.Fatalf("unexpected error: %v", err)
}

var gotSecret corev1.Secret
if err := c.Get(context.Background(), types.NamespacedName{Name: "cloud-credentials", Namespace: "default"}, &gotSecret); err != nil {
t.Errorf("expected credential secret to still exist, got err: %v", err)
}

got := getClusterOrNil(t, c, "mycluster", "default")
if got == nil {
t.Fatal("expected cluster to still exist")
}
if got.Annotations[controller.RetryAnnotation] == "" {
t.Error("expected retry annotation to be set")
}
if !controllerutil.ContainsFinalizer(got, controller.Finalizer) {
t.Error("expected janitor finalizer to still be present")
}
}

// Scenario: removing the janitor finalizer fails on Update → propagated
func TestReconcile_RemoveFinalizer_UpdateError_Propagates(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
secret := newSecret("cloud-credentials", "default") // no credential-policy-delete annotation

r, _ := newReconcilerWithInterceptors(
func(context.Context, openstack.PurgeOptions) error { return nil },
interceptor.Funcs{
Update: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.UpdateOption) error {
return errors.New("update failed")
},
},
cluster, secret,
)

_, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default"))
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "removing finalizer:") {
t.Errorf("expected error to wrap %q, got: %v", "removing finalizer:", err)
}
}

// Scenario: credential policy "delete", last finalizer, but deleting the
// secret fails with a non-NotFound error → propagated
func TestDeleteSecret_ErrorPath_ViaReconcile(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
secret := newSecret("cloud-credentials", "default")
secret.Annotations = map[string]string{controller.CredentialPolicyAnnotation: controller.PolicyDelete}

r, _ := newReconcilerWithInterceptors(
func(context.Context, openstack.PurgeOptions) error { return nil },
interceptor.Funcs{
Delete: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.DeleteOption) error {
return errors.New("delete failed")
},
},
cluster, secret,
)

_, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default"))
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "deleting credential secret:") {
t.Errorf("expected error to wrap %q, got: %v", "deleting credential secret:", err)
}
}

// Scenario: PurgeFunc is nil → falls back to the real openstack.PurgeResources,
// which fails fast (no matching cloud in clouds.yaml) and triggers a retry.
func TestPurge_NilPurgeFunc_FallsBackToPurgeResources(t *testing.T) {
cluster := newCluster("mycluster", "default", withFinalizer, withDeletionTimestamp)
secret := newSecret("cloud-credentials", "default") // clouds.yaml: "clouds: {}" — no "openstack" entry
r, c := newReconciler(nil, cluster, secret) // PurgeFunc left nil

if _, err := r.Reconcile(context.Background(), reconcileRequest("mycluster", "default")); err != nil {
t.Fatalf("expected nil (retry handled internally), got: %v", err)
}

got := getClusterOrNil(t, c, "mycluster", "default")
if got == nil {
t.Fatal("cluster not found after reconcile")
}
if got.Annotations[controller.RetryAnnotation] == "" {
t.Error("expected retry annotation to be set after fallback PurgeResources failure")
}
}
Loading
Loading