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
9 changes: 8 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"flag"
"os"
"path/filepath"
"time"

// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
Expand All @@ -40,6 +41,7 @@ import (

operatorv1alpha1 "github.com/redhat-data-and-ai/unstructured-data-controller/api/v1alpha1"
"github.com/redhat-data-and-ai/unstructured-data-controller/internal/controller"
"github.com/redhat-data-and-ai/unstructured-data-controller/pkg/awsclienthandler"
// +kubebuilder:scaffold:imports
)

Expand Down Expand Up @@ -289,8 +291,13 @@ func main() {
os.Exit(1)
}

ctx := ctrl.SetupSignalHandler()

setupLog.Info("initializing SQS client cache")
awsclienthandler.InitSQSCache(ctx, 30*time.Minute)

setupLog.Info("starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
if err := mgr.Start(ctx); err != nil {
setupLog.Error(err, "problem running manager")
os.Exit(1)
}
Expand Down
19 changes: 12 additions & 7 deletions internal/controller/sourcecrawler_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"time"

corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
Expand Down Expand Up @@ -77,6 +78,10 @@ func (r *SourceCrawlerReconciler) Reconcile(ctx context.Context, req ctrl.Reques

sourceCrawlerCR := &operatorv1alpha1.SourceCrawler{}
if err := r.Get(ctx, req.NamespacedName, sourceCrawlerCR); err != nil {
if apierrors.IsNotFound(err) {
awsclienthandler.DeleteSQSClient(req.String())
return ctrl.Result{}, nil
}
Comment on lines +81 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-critical critical

Using only req.Name as the cache key can lead to cache collisions if multiple SourceCrawler resources with the same name exist in different namespaces. Since this operator can watch multiple namespaces, we should use the namespaced name (e.g., req.NamespacedName.String()) as the cache key to prevent cross-namespace client leakage.

Suggested change
if apierrors.IsNotFound(err) {
awsclienthandler.DeleteSQSClient(req.Name)
return ctrl.Result{}, nil
}
if apierrors.IsNotFound(err) {
awsclienthandler.DeleteSQSClient(req.NamespacedName.String())
return ctrl.Result{}, nil
}

logger.Error(err, "failed to get SourceCrawler CR")
return ctrl.Result{}, err
}
Expand Down Expand Up @@ -161,24 +166,24 @@ func (r *SourceCrawlerReconciler) Reconcile(ctx context.Context, req ctrl.Reques
if err != nil {
return ctrl.Result{}, r.handleError(ctx, sourceCrawlerCR, fmt.Errorf("failed to get source credentials for SQS: %w", err))
}
if _, err := awsclienthandler.NewSQSClientFromConfig(ctx, sourceAWSConfig); err != nil {
namespacedName := types.NamespacedName{Namespace: sourceCrawlerCR.Namespace, Name: sourceCrawlerCR.Name}.String()
if _, err := awsclienthandler.NewSQSClientFromConfig(ctx, sourceAWSConfig, namespacedName); err != nil {
return ctrl.Result{}, r.handleError(ctx, sourceCrawlerCR, fmt.Errorf("failed to create SQS client: %w", err))
}
return handleSQSWakeUp(ctx, sqsQueueURL, sourceCrawlerConfig.S3Config.Bucket, sourceCrawlerConfig.S3Config.Prefix), nil
return handleSQSWakeUp(ctx, sqsQueueURL, sourceCrawlerConfig.S3Config.Bucket, sourceCrawlerConfig.S3Config.Prefix, namespacedName), nil
}
}
return ctrl.Result{RequeueAfter: defaultCrawlerResyncInterval}, nil
}

func handleSQSWakeUp(ctx context.Context, queueURL, bucket, prefix string) ctrl.Result {
func handleSQSWakeUp(ctx context.Context, queueURL, bucket, prefix string, pipelineName string) ctrl.Result {
logger := log.FromContext(ctx)

sqsClient, err := awsclienthandler.GetSQSClient()
if err != nil {
logger.Error(err, "failed to initialize SQS client")
sqsClient, ok := awsclienthandler.GetSQSClient(pipelineName)
if !ok {
logger.Info("SQS client not found for the pipeline, will try again in a bit ...")
return ctrl.Result{RequeueAfter: 10 * time.Second}
}

// DrainSQSQueue long-polls (up to 20s), so it blocks until messages
// arrive or the timeout expires — no separate poll interval needed.
hasMessages, err := awsclienthandler.DrainSQSQueue(ctx, sqsClient, queueURL, bucket, prefix)
Expand Down
111 changes: 97 additions & 14 deletions pkg/awsclienthandler/sqs_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,93 @@ package awsclienthandler
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"sigs.k8s.io/controller-runtime/pkg/log"
)

var (
SQSClient *sqs.Client
)
type SQSClientCache struct {
mu sync.Mutex
clients map[string]*sqsEntry
}

type sqsEntry struct {
client *sqs.Client
awsConfig AWSConfig
lastUsed time.Time
}
Comment on lines +36 to +40

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

To support credential rotation and updates, we should store the AWSConfig in the sqsEntry so that we can detect when the configuration has changed and recreate the client accordingly.

Suggested change
type sqsEntry struct {
client *sqs.Client
lastUsed time.Time
}
type sqsEntry struct {
client *sqs.Client
awsConfig AWSConfig
lastUsed time.Time
}


var sqsClientCache = &SQSClientCache{
clients: make(map[string]*sqsEntry),
}

func InitSQSCache(ctx context.Context, ttl time.Duration) {
sqsClientCache.startCleanup(ctx, ttl)
}

func (c *SQSClientCache) getClient(pipelineName string) (*sqsEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.clients[pipelineName]
if ok {
entry.lastUsed = time.Now()
}
return entry, ok
}

func (c *SQSClientCache) setClient(pipelineName string, entry *sqsEntry) {
c.mu.Lock()
defer c.mu.Unlock()
if c.clients == nil {
c.clients = make(map[string]*sqsEntry)
}
c.clients[pipelineName] = entry
}

func (c *SQSClientCache) startCleanup(ctx context.Context, ttl time.Duration) {
if ttl <= 0 {
return
}
ticker := time.NewTicker(ttl / 2)
Comment on lines +69 to +73

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

If ttl is less than or equal to 0, time.NewTicker(ttl / 2) will panic. We should add a defensive check to prevent panics if an invalid TTL is passed.

func (c *SQSClientCache) startCleanup(ctx context.Context, ttl time.Duration) {
	if ttl <= 0 {
		return
	}
	ticker := time.NewTicker(ttl / 2)

go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.evictStale(ttl)
}
}
}()
}

func (c *SQSClientCache) evictStale(ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, entry := range c.clients {
if now.Sub(entry.lastUsed) > ttl {
delete(c.clients, key)
}
}
}

// NewSQSClientFromConfig creates and returns an Amazon SQS client using the provided context and AWS configuration.
func NewSQSClientFromConfig(ctx context.Context, awsConfig *AWSConfig) (*sqs.Client, error) {
func NewSQSClientFromConfig(ctx context.Context, awsConfig *AWSConfig, pipelineName string) (*sqs.Client, error) {
logger := log.FromContext(ctx)
if SQSClient != nil {
return SQSClient, nil

entry, ok := sqsClientCache.getClient(pipelineName)
if ok && awsConfig != nil && entry.awsConfig == *awsConfig {
logger.Info("Using existing SQS client for the pipeline", "pipelineName", pipelineName)
return entry.client, nil
}

logger.Info("Creating new SQS client for the pipeline", "pipelineName", pipelineName)
cfg, err := getAWSConfig(ctx, awsConfig)
if err != nil {
return nil, err
Expand All @@ -48,17 +116,32 @@ func NewSQSClientFromConfig(ctx context.Context, awsConfig *AWSConfig) (*sqs.Cli
o.BaseEndpoint = aws.String(awsConfig.Endpoint)
}
}
SQSClient = sqs.NewFromConfig(cfg, sqsOptions)
logger.Info("SQS client initialized ...")
return SQSClient, nil

sqsClient := sqs.NewFromConfig(cfg, sqsOptions)

var cfgVal AWSConfig
if awsConfig != nil {
cfgVal = *awsConfig
}
newEntry := &sqsEntry{client: sqsClient, awsConfig: cfgVal, lastUsed: time.Now()}
sqsClientCache.setClient(pipelineName, newEntry)

return sqsClient, nil
}

func DeleteSQSClient(pipelineName string) {
sqsClientCache.mu.Lock()
defer sqsClientCache.mu.Unlock()
delete(sqsClientCache.clients, pipelineName)
}

// GetSQSClient returns the initialized Amazon SQS client instance.
func GetSQSClient() (*sqs.Client, error) {
if SQSClient == nil {
return nil, errors.New("SQS client not initialized yet")
func GetSQSClient(pipelineName string) (*sqs.Client, bool) {
entry, ok := sqsClientCache.getClient(pipelineName)
if !ok {
return nil, false
}
return SQSClient, nil
return entry.client, true
}

const sqsLongPollSeconds = 20
Expand Down
11 changes: 11 additions & 0 deletions test/e2e/unstructured_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
var kubeClient klient.Client
var sourceS3Client *s3.Client
var destS3Client *s3.Client
var sqsClient *sqs.Client

Check failure on line 65 in test/e2e/unstructured_test.go

View workflow job for this annotation

GitHub Actions / Run E2E Tests

declared and not used: sqsClient

feature.Setup(
func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
Expand All @@ -87,11 +87,18 @@
}

err = awsclienthandler.NewDestinationS3ClientFromConfig(ctx, e2eAWS)
// create SQS client
_, err = awsclienthandler.NewSQSClientFromConfig(ctx, &awsclienthandler.AWSConfig{
Region: "us-east-1",
AccessKeyID: "test",
SecretAccessKey: "test",
Endpoint: localstackURL,
}, "e2e-test")
if err != nil {
t.Fatal(err)
}
Comment on lines 89 to 99

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Newly inserted code swallows the error from NewDestinationS3ClientFromConfig.

Line 89 assigns err from NewDestinationS3ClientFromConfig, but the inserted SQS-client-init block (lines 90-96) immediately reassigns err before it's checked — the original error is discarded and only the new call's error is validated at line 97.

🐛 Proposed fix
 			err = awsclienthandler.NewDestinationS3ClientFromConfig(ctx, e2eAWS)
+			if err != nil {
+				t.Fatal(err)
+			}
+
 			// create SQS client
 			_, err = awsclienthandler.NewSQSClientFromConfig(ctx, &awsclienthandler.AWSConfig{
 				Region:          "us-east-1",
 				AccessKeyID:     "test",
 				SecretAccessKey: "test",
 				Endpoint:        localstackURL,
 			}, "e2e-test")
 			if err != nil {
 				t.Fatal(err)
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
err = awsclienthandler.NewDestinationS3ClientFromConfig(ctx, e2eAWS)
// create SQS client
_, err = awsclienthandler.NewSQSClientFromConfig(ctx, &awsclienthandler.AWSConfig{
Region: "us-east-1",
AccessKeyID: "test",
SecretAccessKey: "test",
Endpoint: localstackURL,
}, "e2e-test")
if err != nil {
t.Fatal(err)
}
err = awsclienthandler.NewDestinationS3ClientFromConfig(ctx, e2eAWS)
if err != nil {
t.Fatal(err)
}
// create SQS client
_, err = awsclienthandler.NewSQSClientFromConfig(ctx, &awsclienthandler.AWSConfig{
Region: "us-east-1",
AccessKeyID: "test",
SecretAccessKey: "test",
Endpoint: localstackURL,
}, "e2e-test")
if err != nil {
t.Fatal(err)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/unstructured_test.go` around lines 89 - 99, Preserve and validate
the error returned by NewDestinationS3ClientFromConfig before initializing the
SQS client, so it cannot be overwritten by the subsequent NewSQSClientFromConfig
assignment. Keep the existing fatal behavior for either client initialization
failure.


sqsClient, err = awsclienthandler.NewSQSClientFromConfig(ctx, e2eAWS)

Check failure on line 101 in test/e2e/unstructured_test.go

View workflow job for this annotation

GitHub Actions / Run E2E Tests

not enough arguments in call to awsclienthandler.NewSQSClientFromConfig
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -131,6 +138,10 @@
}

// create SQS queue
sqsClient, ok := awsclienthandler.GetSQSClient("e2e-test")
if !ok {
t.Fatal("SQS client not found for e2e-test")
}
_, err = sqsClient.CreateQueue(ctx, &sqs.CreateQueueInput{
QueueName: aws.String(unstructuredQueueName),
})
Expand Down
Loading