Skip to content
Open
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/imdario/mergo v0.3.16
github.com/snowflakedb/gosnowflake v1.19.1
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
k8s.io/api v0.34.8
k8s.io/apimachinery v0.34.8
Expand Down
20 changes: 11 additions & 9 deletions internal/controller/controllerconfig_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"time"

Expand Down Expand Up @@ -104,15 +105,16 @@ func (r *ControllerConfigReconciler) Reconcile(ctx context.Context, req ctrl.Req
cacheDirectory = config.Spec.UnstructuredDataProcessingConfig.CacheDirectory

unstructuredSecret = &corev1.Secret{}
if config.Spec.UnstructuredSecret != "" {
if err := r.Get(ctx,
types.NamespacedName{Name: config.Spec.UnstructuredSecret, Namespace: req.Namespace}, unstructuredSecret); err != nil {
logger.Error(err, fmt.Sprintf("error fetching AWS secret %s, retrying in 10 seconds ", config.Spec.UnstructuredSecret))
return ctrl.Result{
Requeue: true,
RequeueAfter: 10 * time.Second,
}, nil
}
if config.Spec.UnstructuredSecret == "" {
return ctrl.Result{}, errors.New("UnstructuredSecret not configured")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of implementing manual checks and complex error handling for the missing UnstructuredSecret within the controller logic, rely on Kubernetes API validation markers. Use +kubebuilder:validation:Required in the CRD definition to enforce this constraint. This aligns with the project's preference for API-level validation over redundant controller-side checks.

References
  1. Rely on Kubernetes API validation markers (e.g., +kubebuilder:validation:Minimum) to enforce constraints on configuration fields, rather than implementing redundant manual checks within the controller logic.

}
if err := r.Get(ctx,
types.NamespacedName{Name: config.Spec.UnstructuredSecret, Namespace: req.Namespace}, unstructuredSecret); err != nil {
logger.Error(err, fmt.Sprintf("error fetching AWS secret %s, retrying in 10 seconds ", config.Spec.UnstructuredSecret))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The log message refers to an "AWS secret", but the field is UnstructuredSecret. It is better to use a generic term like "unstructured secret". Also, consider using structured logging by passing the secret name as a key-value pair instead of using fmt.Sprintf in the message, and remove the trailing space in the log string.

Suggested change
logger.Error(err, fmt.Sprintf("error fetching AWS secret %s, retrying in 10 seconds ", config.Spec.UnstructuredSecret))
logger.Error(err, "error fetching unstructured secret, retrying in 10 seconds", "secret", config.Spec.UnstructuredSecret)

return ctrl.Result{
Requeue: true,
RequeueAfter: 10 * time.Second,
}, nil
}

doclingServeURL := config.Spec.UnstructuredDataProcessingConfig.DoclingServeURL
Expand Down
255 changes: 255 additions & 0 deletions internal/controller/controllerconfig_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package controller

import (
"context"
"testing"

operatorv1alpha1 "github.com/redhat-data-and-ai/unstructured-data-controller/api/v1alpha1"
"github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
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"
)

// covering the following scenerios
// 1. calling reconcile with no-secrets (should return an error, should set the global variables like dataingestionBucket, dataStorageBucket and cacheDirectory)

func TestControllerConfigReconcileWithoutSecret(t *testing.T) {
schema := runtime.NewScheme()
assert.NoError(t, operatorv1alpha1.AddToScheme(schema))
assert.NoError(t, corev1.AddToScheme(schema))

controllerConfig := &operatorv1alpha1.ControllerConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "default",
Generation: 1,
},
Spec: operatorv1alpha1.ControllerConfigSpec{
UnstructuredDataProcessingConfig: operatorv1alpha1.UnstructuredDataProcessingConfigSpec{
IngestionBucket: "data-ingestion-bucket",
DataStorageBucket: "data-storage-bucket",
CacheDirectory: "tmp/cache/",
DoclingServeURL: "http://docling:8080",
MaxConcurrentDoclingTasks: 5,
MaxConcurrentLangchainTasks: 3,
},
},
}

fakeClient := fake.NewClientBuilder().WithScheme(schema).WithObjects(controllerConfig).WithStatusSubresource(controllerConfig).Build()

reconciler := &ControllerConfigReconciler{
Client: fakeClient,
Scheme: schema,
}

req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-config",
Namespace: "default",
},
}

ingestionBucket = ""
dataStorageBucket = ""
cacheDirectory = ""
Comment on lines +58 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The tests rely on package-level global variables (ingestionBucket, dataStorageBucket, cacheDirectory) to manage state. This approach is not thread-safe, as multiple reconciliation workers will race to update these variables. Furthermore, it prevents tests from being run in parallel. These values should be stored as fields within the ControllerConfigReconciler struct or passed as arguments to the functions that need them. Using global variables for state derived from a Custom Resource is a significant anti-pattern in Kubernetes operators and can lead to unpredictable behavior if multiple resources exist.


result, err := reconciler.Reconcile(context.Background(), req)
assert.Error(t, err, "Expected error because there is no unstructured secret found")
assert.Equal(t, "data-ingestion-bucket", ingestionBucket, "Ingestion bucket should be set")
assert.Equal(t, "data-storage-bucket", dataStorageBucket, "Storage bucket should be set")
assert.Equal(t, "tmp/cache/", cacheDirectory, "Cache directory should be set")

t.Logf("Result: %+v", result)
}

func TestControllerConfigReconcileWithSecrets(t *testing.T) {
schema := runtime.NewScheme()
assert.NoError(t, operatorv1alpha1.AddToScheme(schema))
assert.NoError(t, corev1.AddToScheme(schema))

controllerConfig := &operatorv1alpha1.ControllerConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "default",
Generation: 1,
},
Spec: operatorv1alpha1.ControllerConfigSpec{
UnstructuredSecret: "unstructured-secret",
UnstructuredDataProcessingConfig: operatorv1alpha1.UnstructuredDataProcessingConfigSpec{
IngestionBucket: "data-ingestion-bucket",
DataStorageBucket: "data-storage-bucket",
CacheDirectory: "tmp/cache/",
DoclingServeURL: "http://docling:8080",
MaxConcurrentDoclingTasks: 5,
MaxConcurrentLangchainTasks: 3,
},
},
}

secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "unstructured-secret",
Namespace: "default",
},
Data: map[string][]byte{
"SOURCE_AWS_REGION": []byte("us-east-1"),
"SOURCE_AWS_ACCESS_KEY_ID": []byte("fake"),
"SOURCE_AWS_SECRET_ACCESS_KEY": []byte("fake"),
"SOURCE_AWS_ENDPOINT": []byte(""),
"DESTINATION_AWS_REGION": []byte("us-east-1"),
"DESTINATION_AWS_ACCESS_KEY_ID": []byte("fake"),
"DESTINATION_AWS_SECRET_ACCESS_KEY": []byte("fake"),
"DESTINATION_AWS_ENDPOINT": []byte(""),
"FILE_STORE_AWS_REGION": []byte("us-east-1"),
"FILE_STORE_AWS_ACCESS_KEY_ID": []byte("fake"),
"FILE_STORE_AWS_SECRET_ACCESS_KEY": []byte("fake"),
"FILE_STORE_AWS_ENDPOINT": []byte(""),
"SNOWFLAKE_PRIVATE_KEY": []byte("fake-base64-encoded-key"),
},
}

fakeClient := fake.NewClientBuilder().WithScheme(schema).WithObjects(controllerConfig, secret).WithStatusSubresource(controllerConfig).Build()

reconciler := &ControllerConfigReconciler{
Client: fakeClient,
Scheme: schema,
}

req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-config",
Namespace: "default",
},
}

// fetch the secrets to verify their values
fetchedSecret := &corev1.Secret{}
err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "unstructured-secret", Namespace: "default"}, fetchedSecret)
assert.NoError(t, err, "Expected no error")
assert.Equal(t, "us-east-1", string(fetchedSecret.Data["SOURCE_AWS_REGION"]), "Source AWS region should be set")
assert.Equal(t, "fake", string(fetchedSecret.Data["SOURCE_AWS_ACCESS_KEY_ID"]), "Source AWS access key ID should be set")
assert.Equal(t, "fake", string(fetchedSecret.Data["SOURCE_AWS_SECRET_ACCESS_KEY"]), "Source AWS secret access key should be set")
assert.Equal(t, "", string(fetchedSecret.Data["SOURCE_AWS_ENDPOINT"]), "Source AWS endpoint should be set")
assert.Equal(t, "us-east-1", string(fetchedSecret.Data["DESTINATION_AWS_REGION"]), "Destination AWS region should be set")
assert.Equal(t, "fake", string(fetchedSecret.Data["DESTINATION_AWS_ACCESS_KEY_ID"]), "Destination AWS access key ID should be set")
assert.Equal(t, "fake", string(fetchedSecret.Data["DESTINATION_AWS_SECRET_ACCESS_KEY"]), "Destination AWS secret access key should be set")
assert.Equal(t, "", string(fetchedSecret.Data["DESTINATION_AWS_ENDPOINT"]), "Destination AWS endpoint should be set")
assert.Equal(t, "us-east-1", string(fetchedSecret.Data["FILE_STORE_AWS_REGION"]), "File store AWS region should be set")
assert.Equal(t, "fake", string(fetchedSecret.Data["FILE_STORE_AWS_ACCESS_KEY_ID"]), "File store AWS access key ID should be set")
assert.Equal(t, "fake", string(fetchedSecret.Data["FILE_STORE_AWS_SECRET_ACCESS_KEY"]), "File store AWS secret access key should be set")
assert.Equal(t, "", string(fetchedSecret.Data["FILE_STORE_AWS_ENDPOINT"]), "File store AWS endpoint should be set")
assert.Equal(t, "fake-base64-encoded-key", string(fetchedSecret.Data["SNOWFLAKE_PRIVATE_KEY"]), "Snowflake private key should be set")

result, err := reconciler.Reconcile(context.Background(), req)
assert.NoError(t, err, "Expected no error")
t.Logf("Result: %+v", result)

updated := getControllerConfig(t, fakeClient, req.NamespacedName)
assert.True(t, updated.IsHealthy())
assert.Equal(t, int64(1), updated.Status.LastAppliedGeneration)
cond := configReadyCondition(updated)
assert.NotNil(t, cond)
assert.Equal(t, metav1.ConditionTrue, cond.Status)
assert.Equal(t, "SuccessfullyReconciled", cond.Reason)
assert.Equal(t, "Config is healthy", cond.Message)
}

func TestControllerConfigReconcileWithInvalidSSnowflakeRSAKey(t *testing.T) {
schema := runtime.NewScheme()
assert.NoError(t, operatorv1alpha1.AddToScheme(schema))
assert.NoError(t, corev1.AddToScheme(schema))

controllerConfig := &operatorv1alpha1.ControllerConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "default",
Generation: 1,
},
Spec: operatorv1alpha1.ControllerConfigSpec{
UnstructuredSecret: "unstructured-secret",
UnstructuredDataProcessingConfig: operatorv1alpha1.UnstructuredDataProcessingConfigSpec{
IngestionBucket: "data-ingestion-bucket",
DataStorageBucket: "data-storage-bucket",
CacheDirectory: "tmp/cache/",
DoclingServeURL: "http://docling:8080",
MaxConcurrentDoclingTasks: 5,
MaxConcurrentLangchainTasks: 3,
},
SnowflakeConfig: &operatorv1alpha1.SnowflakeConfig{
Account: "test-account",
User: "test-user",
Role: "test-role",
Region: "us-east-1",
Warehouse: "test-warehouse",
},
},
}

secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "unstructured-secret",
Namespace: "default",
},
Data: map[string][]byte{
"SOURCE_AWS_REGION": []byte("us-east-1"),
"SOURCE_AWS_ACCESS_KEY_ID": []byte("fake"),
"SOURCE_AWS_SECRET_ACCESS_KEY": []byte("fake"),
"SOURCE_AWS_ENDPOINT": []byte(""),
"DESTINATION_AWS_REGION": []byte("us-east-1"),
"DESTINATION_AWS_ACCESS_KEY_ID": []byte("fake"),
"DESTINATION_AWS_SECRET_ACCESS_KEY": []byte("fake"),
"DESTINATION_AWS_ENDPOINT": []byte(""),
"FILE_STORE_AWS_REGION": []byte("us-east-1"),
"FILE_STORE_AWS_ACCESS_KEY_ID": []byte("fake"),
"FILE_STORE_AWS_SECRET_ACCESS_KEY": []byte("fake"),
"FILE_STORE_AWS_ENDPOINT": []byte(""),
"SNOWFLAKE_PRIVATE_KEY": []byte("fake-base64-encoded-key"),
},
}

fakeClient := fake.NewClientBuilder().WithScheme(schema).WithObjects(controllerConfig, secret).WithStatusSubresource(controllerConfig).Build()

reconciler := &ControllerConfigReconciler{
Client: fakeClient,
Scheme: schema,
}

req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-config",
Namespace: "default",
},
}

result, err := reconciler.Reconcile(context.Background(), req)
assert.Error(t, err)
assert.ErrorContains(t, err, "failed to decode snowflake private key from secret unstructured-secret")
t.Logf("Result: %+v", result)

updated := getControllerConfig(t, fakeClient, req.NamespacedName)
assert.False(t, updated.IsHealthy())
assert.Equal(t, int64(0), updated.Status.LastAppliedGeneration)
assert.Nil(t, configReadyCondition(updated), "ConfigReady condition should not be written on early failure")
}

// helper functions
func getControllerConfig(t *testing.T, c client.Client, nn types.NamespacedName) *operatorv1alpha1.ControllerConfig {
t.Helper()
cfg := &operatorv1alpha1.ControllerConfig{}
assert.NoError(t, c.Get(context.Background(), nn, cfg))
return cfg
}
func configReadyCondition(cfg *operatorv1alpha1.ControllerConfig) *metav1.Condition {
for i := range cfg.Status.Conditions {
if cfg.Status.Conditions[i].Type == operatorv1alpha1.ConfigCondition {
return &cfg.Status.Conditions[i]
}
}
return nil
}
21 changes: 21 additions & 0 deletions vendor/github.com/stretchr/testify/LICENSE

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading