From 3a6b97b607236ae1f0e377af8659e9f6089cf92a Mon Sep 17 00:00:00 2001 From: Derek Higgins Date: Fri, 26 Sep 2025 16:00:04 +0100 Subject: [PATCH 1/2] Fix operator config ConfigMap watching o Add operator config detection in configMapUpdatePredicate o Update feature flags when operator config changes o Trigger reconciliation of all LlamaStackDistributions on operator config change Signed-off-by: Derek Higgins --- .../llamastackdistribution_controller.go | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/controllers/llamastackdistribution_controller.go b/controllers/llamastackdistribution_controller.go index 5cf8c7323..3e1b3406f 100644 --- a/controllers/llamastackdistribution_controller.go +++ b/controllers/llamastackdistribution_controller.go @@ -83,6 +83,10 @@ const ( // When a ConfigMap's data changes, it automatically triggers reconciliation of the referencing // LlamaStackDistribution, which recalculates a content-based hash and updates the deployment's // pod template annotations. This causes Kubernetes to restart the pods with the updated configuration. +// +// Operator ConfigMap Watching Feature: +// This reconciler also watches for changes to the operator configuration ConfigMap. When the operator +// config changes, it triggers reconciliation of all LlamaStackDistribution resources. type LlamaStackDistributionReconciler struct { client.Client Scheme *runtime.Scheme @@ -593,6 +597,21 @@ func (r *LlamaStackDistributionReconciler) configMapUpdatePredicate(e event.Upda return false } + // Parse the feature flags if the operator config ConfigMap has changed + operatorNamespace, err := deploy.GetOperatorNamespace() + if err != nil { + return false + } + if newConfigMap.Name == operatorConfigData && newConfigMap.Namespace == operatorNamespace { + EnableNetworkPolicy, err := parseFeatureFlags(newConfigMap.Data) + if err != nil { + log.FromContext(context.Background()).Error(err, "Failed to parse feature flags") + } else { + r.EnableNetworkPolicy = EnableNetworkPolicy + } + return true + } + // Only proceed if this ConfigMap is referenced by any LlamaStackDistribution if !r.isConfigMapReferenced(newConfigMap) { return false @@ -758,6 +777,27 @@ func (r *LlamaStackDistributionReconciler) manuallyCheckConfigMapReference(confi // findLlamaStackDistributionsForConfigMap maps ConfigMap changes to LlamaStackDistribution reconcile requests. func (r *LlamaStackDistributionReconciler) findLlamaStackDistributionsForConfigMap(ctx context.Context, configMap client.Object) []reconcile.Request { + logger := log.FromContext(ctx).WithValues( + "configMapName", configMap.GetName(), + "configMapNamespace", configMap.GetNamespace()) + + operatorNamespace, err := deploy.GetOperatorNamespace() + if err != nil { + log.FromContext(context.Background()).Error(err, "Failed to get operator namespace for config map event processing") + return nil + } + // If the operator config was changed, we reconcile all LlamaStackDistributions + if configMap.GetName() == operatorConfigData && configMap.GetNamespace() == operatorNamespace { + // List all LlamaStackDistribution resources across all namespaces + allLlamaStacks := llamav1alpha1.LlamaStackDistributionList{} + err = r.List(ctx, &allLlamaStacks) + if err != nil { + logger.Error(err, "Failed to list all LlamaStackDistributions for operator config change") + return nil + } + return r.convertToReconcileRequests(allLlamaStacks) + } + // Try field indexer lookup first attachedLlamaStacks, found := r.tryFieldIndexerLookup(ctx, configMap) if !found { From b0236adfbd016ae519cadca0e29cb9215d4f3f28 Mon Sep 17 00:00:00 2001 From: Derek Higgins Date: Tue, 30 Sep 2025 16:45:53 +0100 Subject: [PATCH 2/2] feat: ConfigMap image mapping overrides for LLS Distro Implements a mechanism for the Llama Stack Operator to read and apply LLS Distribution image updates from a ConfigMap, enabling independent patching for security fixes or bug fixes without requiring a new LLS Operator. - Add ImageMappingOverrides field to LlamaStackDistributionReconciler - Implement parseImageMappingOverrides() to read image-overrides from ConfigMap - Support symbolic name mapping (e.g., `starter`) to specific images - Included unit tests The operator now reads image overrides from the 'image-overrides' key in the operator ConfigMap, supporting YAML format with version-to-image mappings. Overrides take precedence over default distribution images and are refreshed on each reconciler initialization. Closes: RHAIENG-1079 Signed-off-by: Derek Higgins --- README.md | 29 +++ .../llamastackdistribution_controller.go | 152 ++++++++++---- .../llamastackdistribution_controller_test.go | 186 ++++++++++++++++++ controllers/resource_helper.go | 6 + go.mod | 9 +- go.sum | 20 +- tests/e2e/creation_test.go | 106 ++++++++++ 7 files changed, 456 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index a10a95333..7d602ad5e 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,35 @@ kubectl apply -f feature-flags.yaml Within the next reconciliation loop the operator will begin creating a `-network-policy` resource for each distribution. Set `enabled: false` (or remove the block) to turn the feature back off; the operator will delete the previously managed policies. +## Image Mapping Overrides + +The operator supports ConfigMap-driven image updates for LLS Distribution images. This allows independent patching for security fixes or bug fixes without requiring a new operator version. + +### Configuration + +Create or update the operator ConfigMap with an `image-overrides` key: + +```yaml + + image-overrides: | + starter-gpu: quay.io/custom/llama-stack:starter-gpu + starter: quay.io/custom/llama-stack:starter +``` + +### Configuration Format + +Use the distribution name directly as the key (e.g., `starter-gpu`, `starter`). The operator will apply these overrides automatically + +### Example Usage + +To update the LLS Distribution image for all `starter` distributions: + +```bash +kubectl patch configmap llama-stack-operator-config -n llama-stack-k8s-operator-system --type merge -p '{"data":{"image-overrides":"starter: quay.io/opendatahub/llama-stack:latest"}}' +``` + +This will cause all LlamaStackDistribution resources using the `starter` distribution to restart with the new image. + ## Developer Guide ### Prerequisites diff --git a/controllers/llamastackdistribution_controller.go b/controllers/llamastackdistribution_controller.go index 3e1b3406f..278905b18 100644 --- a/controllers/llamastackdistribution_controller.go +++ b/controllers/llamastackdistribution_controller.go @@ -33,6 +33,7 @@ import ( "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" + "github.com/google/go-containerregistry/pkg/name" llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" "github.com/llamastack/llama-stack-k8s-operator/pkg/cluster" "github.com/llamastack/llama-stack-k8s-operator/pkg/deploy" @@ -92,6 +93,8 @@ type LlamaStackDistributionReconciler struct { Scheme *runtime.Scheme // Feature flags EnableNetworkPolicy bool + // Image mapping overrides + ImageMappingOverrides map[string]string // Cluster info ClusterInfo *cluster.ClusterInfo httpClient *http.Client @@ -597,21 +600,40 @@ func (r *LlamaStackDistributionReconciler) configMapUpdatePredicate(e event.Upda return false } - // Parse the feature flags if the operator config ConfigMap has changed + // Check if this is the operator config ConfigMap + if r.handleOperatorConfigUpdate(newConfigMap) { + return true + } + + // Handle referenced ConfigMap updates + return r.handleReferencedConfigMapUpdate(oldConfigMap, newConfigMap) +} + +// handleOperatorConfigUpdate processes updates to the operator config ConfigMap. +func (r *LlamaStackDistributionReconciler) handleOperatorConfigUpdate(configMap *corev1.ConfigMap) bool { operatorNamespace, err := deploy.GetOperatorNamespace() if err != nil { return false } - if newConfigMap.Name == operatorConfigData && newConfigMap.Namespace == operatorNamespace { - EnableNetworkPolicy, err := parseFeatureFlags(newConfigMap.Data) - if err != nil { - log.FromContext(context.Background()).Error(err, "Failed to parse feature flags") - } else { - r.EnableNetworkPolicy = EnableNetworkPolicy - } - return true + + if configMap.Name != operatorConfigData || configMap.Namespace != operatorNamespace { + return false } + // Update feature flags + EnableNetworkPolicy, err := parseFeatureFlags(configMap.Data) + if err != nil { + log.FromContext(context.Background()).Error(err, "Failed to parse feature flags") + } else { + r.EnableNetworkPolicy = EnableNetworkPolicy + } + + r.ImageMappingOverrides = ParseImageMappingOverrides(context.Background(), configMap.Data) + return true +} + +// handleReferencedConfigMapUpdate processes updates to referenced ConfigMaps. +func (r *LlamaStackDistributionReconciler) handleReferencedConfigMapUpdate(oldConfigMap, newConfigMap *corev1.ConfigMap) bool { // Only proceed if this ConfigMap is referenced by any LlamaStackDistribution if !r.isConfigMapReferenced(newConfigMap) { return false @@ -783,7 +805,7 @@ func (r *LlamaStackDistributionReconciler) findLlamaStackDistributionsForConfigM operatorNamespace, err := deploy.GetOperatorNamespace() if err != nil { - log.FromContext(context.Background()).Error(err, "Failed to get operator namespace for config map event processing") + logger.Error(err, "Failed to get operator namespace for config map event processing") return nil } // If the operator config was changed, we reconcile all LlamaStackDistributions @@ -1672,53 +1694,103 @@ func NewLlamaStackDistributionReconciler(ctx context.Context, client client.Clie return nil, fmt.Errorf("failed to get operator namespace: %w", err) } - // Get the ConfigMap - // If the ConfigMap doesn't exist, create it with default feature flags - // If the ConfigMap exists, parse the feature flags from the Configmap + // Initialize operator config ConfigMap + configMap, err := initializeOperatorConfigMap(ctx, client, operatorNamespace) + if err != nil { + return nil, err + } + + // Parse feature flags from ConfigMap + enableNetworkPolicy, err := parseFeatureFlags(configMap.Data) + if err != nil { + return nil, fmt.Errorf("failed to parse feature flags: %w", err) + } + + // Parse image mapping overrides from ConfigMap + imageMappingOverrides := ParseImageMappingOverrides(ctx, configMap.Data) + + return &LlamaStackDistributionReconciler{ + Client: client, + Scheme: scheme, + EnableNetworkPolicy: enableNetworkPolicy, + ImageMappingOverrides: imageMappingOverrides, + ClusterInfo: clusterInfo, + httpClient: &http.Client{Timeout: 5 * time.Second}, + }, nil +} + +// initializeOperatorConfigMap gets or creates the operator config ConfigMap. +func initializeOperatorConfigMap(ctx context.Context, c client.Client, operatorNamespace string) (*corev1.ConfigMap, error) { configMap := &corev1.ConfigMap{} configMapName := types.NamespacedName{ Name: operatorConfigData, Namespace: operatorNamespace, } - if err = client.Get(ctx, configMapName, configMap); err != nil { - if !k8serrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get ConfigMap: %w", err) - } + err := c.Get(ctx, configMapName, configMap) + if err == nil { + return configMap, nil + } - // ConfigMap doesn't exist, create it with defaults - configMap, err = createDefaultConfigMap(configMapName) - if err != nil { - return nil, fmt.Errorf("failed to generate default configMap: %w", err) + if !k8serrors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get ConfigMap: %w", err) + } + + // ConfigMap doesn't exist, create it with defaults + configMap, err = createDefaultConfigMap(configMapName) + if err != nil { + return nil, fmt.Errorf("failed to generate default configMap: %w", err) + } + + if err = c.Create(ctx, configMap); err != nil { + return nil, fmt.Errorf("failed to create ConfigMap: %w", err) + } + + return configMap, nil +} + +func ParseImageMappingOverrides(ctx context.Context, configMapData map[string]string) map[string]string { + imageMappingOverrides := make(map[string]string) + logger := log.FromContext(ctx) + + // Look for the image-overrides key in the ConfigMap data + if overridesYAML, exists := configMapData["image-overrides"]; exists { + // Parse the YAML content + var overrides map[string]string + if err := yaml.Unmarshal([]byte(overridesYAML), &overrides); err != nil { + // Log error but continue with empty overrides + logger.V(1).Info("failed to parse image-overrides YAML", "error", err) + return imageMappingOverrides } - if err = client.Create(ctx, configMap); err != nil { - return nil, fmt.Errorf("failed to create ConfigMap: %w", err) + // Validate and copy the parsed overrides to our result map + for version, image := range overrides { + // Validate the image reference format + if _, err := name.ParseReference(image); err != nil { + logger.V(1).Info( + "skipping invalid image override", + "version", version, + "image", image, + "error", err, + ) + continue + } + imageMappingOverrides[version] = image } } - // Parse feature flags from ConfigMap - enableNetworkPolicy, err := parseFeatureFlags(configMap.Data) - if err != nil { - return nil, fmt.Errorf("failed to parse feature flags: %w", err) - } - return &LlamaStackDistributionReconciler{ - Client: client, - Scheme: scheme, - EnableNetworkPolicy: enableNetworkPolicy, - ClusterInfo: clusterInfo, - httpClient: &http.Client{Timeout: 5 * time.Second}, - }, nil + return imageMappingOverrides } // NewTestReconciler creates a reconciler for testing, allowing injection of a custom http client and feature flags. func NewTestReconciler(client client.Client, scheme *runtime.Scheme, clusterInfo *cluster.ClusterInfo, httpClient *http.Client, enableNetworkPolicy bool) *LlamaStackDistributionReconciler { return &LlamaStackDistributionReconciler{ - Client: client, - Scheme: scheme, - ClusterInfo: clusterInfo, - httpClient: httpClient, - EnableNetworkPolicy: enableNetworkPolicy, + Client: client, + Scheme: scheme, + ClusterInfo: clusterInfo, + httpClient: httpClient, + EnableNetworkPolicy: enableNetworkPolicy, + ImageMappingOverrides: make(map[string]string), } } diff --git a/controllers/llamastackdistribution_controller_test.go b/controllers/llamastackdistribution_controller_test.go index 0b1b72306..9e956fe9a 100644 --- a/controllers/llamastackdistribution_controller_test.go +++ b/controllers/llamastackdistribution_controller_test.go @@ -796,3 +796,189 @@ InvalidCertificateDataThatIsNotValidX509 "error should indicate X.509 parsing failure") }) } + +func TestParseImageMappingOverrides_SingleOverride(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + // Test data with single override + configMapData := map[string]string{ + "image-overrides": "starter: quay.io/custom/llama-stack:starter", + } + + // Call the function + result := controllers.ParseImageMappingOverrides(t.Context(), configMapData) + + // Assertions + require.Len(t, result, 1, "Should have exactly one override") + require.Equal(t, "quay.io/custom/llama-stack:starter", result["starter"], "Override should match expected value") +} + +func TestParseImageMappingOverrides_InvalidYAML(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + // Test data with invalid YAML + configMapData := map[string]string{ + "image-overrides": "invalid: yaml: content: [", + } + + // Call the function + result := controllers.ParseImageMappingOverrides(t.Context(), configMapData) + + // Assertions - should return empty map on error + require.Empty(t, result, "Should return empty map when YAML is invalid") +} + +func TestParseImageMappingOverrides_InvalidImageReference(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + // Test data with invalid image references + configMapData := map[string]string{ + "image-overrides": ` +starter: quay.io/valid/image:tag +invalid: not a valid image reference!!! +another: quay.io/another/valid:image +malformed: UPPERCASE/INVALID:IMAGE +onemore: registry.redhat.io/org/imagename@sha256:1234567890123456789012345678901234567890123456789012345678901234 +`, + } + + // Call the function + result := controllers.ParseImageMappingOverrides(t.Context(), configMapData) + + // Assertions - should skip invalid entries and keep valid ones + require.Len(t, result, 3, "Should have exactly two valid overrides") + require.Equal(t, "quay.io/valid/image:tag", result["starter"], "Valid starter override should be present") + require.Equal(t, "quay.io/another/valid:image", result["another"], "Valid another override should be present") + require.Equal(t, + "registry.redhat.io/org/imagename@sha256:1234567890123456789012345678901234567890123456789012345678901234", + result["onemore"], "Valid onemore override should be present") + require.NotContains(t, result, "invalid", "Invalid entry should be skipped") + require.NotContains(t, result, "malformed", "Malformed entry should be skipped") +} + +func TestNewLlamaStackDistributionReconciler_WithImageOverrides(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + // Create operator namespace + operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system") + t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name) + + // Create test ConfigMap with image overrides + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "llama-stack-operator-config", + Namespace: operatorNamespace.Name, + }, + Data: map[string]string{ + "image-overrides": "starter: quay.io/custom/llama-stack:starter", + "featureFlags": `enableNetworkPolicy: + enabled: false`, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), configMap)) + + // Create test cluster info + clusterInfo := &cluster.ClusterInfo{ + OperatorNamespace: operatorNamespace.Name, + DistributionImages: map[string]string{"starter": "default-image"}, + } + + // Call the function + reconciler, err := controllers.NewLlamaStackDistributionReconciler( + t.Context(), + k8sClient, + scheme.Scheme, + clusterInfo, + ) + + // Assertions + require.NoError(t, err, "Should create reconciler successfully") + require.NotNil(t, reconciler, "Reconciler should not be nil") + require.Len(t, reconciler.ImageMappingOverrides, 1, "Should have one image override") + require.Equal(t, "quay.io/custom/llama-stack:starter", + reconciler.ImageMappingOverrides["starter"], "Override should match expected value") + require.False(t, reconciler.EnableNetworkPolicy, "Network policy should be disabled") +} + +func TestConfigMapUpdateTriggersReconciliation(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + // Create test namespace + namespace := createTestNamespace(t, "test-configmap-update") + operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system") + t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name) + + // Create initial ConfigMap + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "llama-stack-operator-config", + Namespace: operatorNamespace.Name, + }, + Data: map[string]string{ + "featureFlags": `enableNetworkPolicy: + enabled: false`, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), configMap)) + + // Create LlamaStackDistribution instance using starter + instance := NewDistributionBuilder(). + WithName("test-configmap-update"). + WithNamespace(namespace.Name). + WithDistribution("starter"). + Build() + require.NoError(t, k8sClient.Create(t.Context(), instance)) + + // Create reconciler with initial overrides + clusterInfo := &cluster.ClusterInfo{ + OperatorNamespace: operatorNamespace.Name, + DistributionImages: map[string]string{"starter": "default-starter-image"}, + } + + reconciler, err := controllers.NewLlamaStackDistributionReconciler( + t.Context(), + k8sClient, + scheme.Scheme, + clusterInfo, + ) + require.NoError(t, err) + + // Initial reconciliation + _, err = reconciler.Reconcile(t.Context(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, + }) + require.NoError(t, err) + + // Get initial deployment and verify it uses the first override + deployment := &appsv1.Deployment{} + waitForResource(t, k8sClient, instance.Namespace, instance.Name, deployment) + initialImage := deployment.Spec.Template.Spec.Containers[0].Image + require.Equal(t, "default-starter-image", initialImage, + "Initial deployment should use distribution image") + + // Update ConfigMap with new overrides + configMap.Data["image-overrides"] = "starter: quay.io/custom/llama-stack:starter" + require.NoError(t, k8sClient.Update(t.Context(), configMap)) + + // Simulate ConfigMap update by recreating reconciler (in real scenario this would be triggered by watch) + updatedReconciler, err := controllers.NewLlamaStackDistributionReconciler( + t.Context(), + k8sClient, + scheme.Scheme, + clusterInfo, + ) + require.NoError(t, err) + + // Reconcile with updated overrides + _, err = updatedReconciler.Reconcile(t.Context(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, + }) + require.NoError(t, err) + + // Verify deployment was updated with new image + waitForResourceWithKeyAndCondition( + t, k8sClient, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, + deployment, func() bool { + return deployment.Spec.Template.Spec.Containers[0].Image == "quay.io/custom/llama-stack:starter" + }, "Deployment should be updated with new image") +} diff --git a/controllers/resource_helper.go b/controllers/resource_helper.go index 428009822..87cc5ebd8 100644 --- a/controllers/resource_helper.go +++ b/controllers/resource_helper.go @@ -454,6 +454,12 @@ func (r *LlamaStackDistributionReconciler) resolveImage(distribution llamav1alph if _, exists := distributionMap[distribution.Name]; !exists { return "", fmt.Errorf("failed to validate distribution name: %s", distribution.Name) } + // Check for image override in the operator config ConfigMap + // The override is keyed by distribution name only (e.g., "starter") + // This allows the same override to apply across all distributions + if override, exists := r.ImageMappingOverrides[distribution.Name]; exists { + return override, nil + } return distributionMap[distribution.Name], nil case distribution.Image != "": return distribution.Image, nil diff --git a/go.mod b/go.mod index e5f9612ee..2af332ba8 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/go-openapi/jsonpointer v0.22.3 github.com/google/go-cmp v0.7.0 + github.com/google/go-containerregistry v0.20.6 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.27.1 gopkg.in/yaml.v2 v2.4.0 @@ -45,6 +46,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.22.0 // indirect @@ -57,14 +59,13 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.40.0 // indirect + golang.org/x/net v0.41.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.14.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.33.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/go.sum b/go.sum index 09d298d5c..97d47b8f2 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnL github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -81,6 +83,8 @@ github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -132,15 +136,15 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -150,16 +154,16 @@ golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/tests/e2e/creation_test.go b/tests/e2e/creation_test.go index 1016cec2d..c168e2ca5 100644 --- a/tests/e2e/creation_test.go +++ b/tests/e2e/creation_test.go @@ -58,6 +58,10 @@ func runCreationTestsForDistribution(t *testing.T, distType string) *v1alpha1.Ll testServiceAccountOverride(t, llsdistributionCR) }) + t.Run("should apply image mapping overrides from ConfigMap", func(t *testing.T) { + testImageMappingOverrides(t, llsdistributionCR) + }) + return llsdistributionCR } @@ -404,3 +408,105 @@ func validateProviders(t *testing.T, distribution *v1alpha1.LlamaStackDistributi require.NotEmpty(t, provider.Config, "Provider config should not be empty") } } + +func testImageMappingOverrides(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { + t.Helper() + + // Get the current deployment to save the original image + deployment := &appsv1.Deployment{} + require.NoError(t, TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ + Namespace: distribution.Namespace, + Name: distribution.Name, + }, deployment)) + originalImage := deployment.Spec.Template.Spec.Containers[0].Image + + // Get the operator ConfigMap + operatorConfigMap := &corev1.ConfigMap{} + require.NoError(t, TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ + Namespace: TestOpts.OperatorNS, + Name: "llama-stack-operator-config", + }, operatorConfigMap)) + + // Add image override for the distribution type + testOverrideImage := "quay.io/test/llama-stack:override-test@sha256:abc123" + if operatorConfigMap.Data == nil { + operatorConfigMap.Data = make(map[string]string) + } + operatorConfigMap.Data["image-overrides"] = distribution.Spec.Server.Distribution.Name + ": " + testOverrideImage + + // Update the ConfigMap + require.NoError(t, TestEnv.Client.Update(TestEnv.Ctx, operatorConfigMap), + "Failed to update operator ConfigMap with image overrides") + + // Wait for the operator to reconcile and update the deployment + // The ConfigMap change should trigger reconciliation of all distributions + err := wait.PollUntilContextTimeout(TestEnv.Ctx, 10*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ + Namespace: distribution.Namespace, + Name: distribution.Name, + }, deployment) + if getErr != nil { + return false, getErr + } + currentImage := deployment.Spec.Template.Spec.Containers[0].Image + t.Logf("Current deployment image: %s (waiting for: %s)", currentImage, testOverrideImage) + return currentImage == testOverrideImage, nil + }) + requireNoErrorWithDebugging(t, TestEnv, err, + "Deployment should be updated with override image from ConfigMap", + distribution.Namespace, distribution.Name) + + // Verify the deployment is using the override image + require.NoError(t, TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ + Namespace: distribution.Namespace, + Name: distribution.Name, + }, deployment)) + assert.Equal(t, testOverrideImage, deployment.Spec.Template.Spec.Containers[0].Image, + "Deployment should use image from ConfigMap override") + + // Test updating the override to a different image + updatedOverrideImage := "quay.io/test/llama-stack:override-test-v2@sha256:def456" + operatorConfigMap.Data["image-overrides"] = distribution.Spec.Server.Distribution.Name + ": " + updatedOverrideImage + require.NoError(t, TestEnv.Client.Update(TestEnv.Ctx, operatorConfigMap), + "Failed to update operator ConfigMap with new image override") + + // Wait for the deployment to update with the new override + err = wait.PollUntilContextTimeout(TestEnv.Ctx, 10*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ + Namespace: distribution.Namespace, + Name: distribution.Name, + }, deployment) + if getErr != nil { + return false, getErr + } + currentImage := deployment.Spec.Template.Spec.Containers[0].Image + t.Logf("Current deployment image: %s (waiting for updated override: %s)", currentImage, updatedOverrideImage) + return currentImage == updatedOverrideImage, nil + }) + requireNoErrorWithDebugging(t, TestEnv, err, + "Deployment should be updated with new override image from ConfigMap", + distribution.Namespace, distribution.Name) + + // Clean up - remove the image-overrides entry from the ConfigMap + delete(operatorConfigMap.Data, "image-overrides") + + require.NoError(t, TestEnv.Client.Update(TestEnv.Ctx, operatorConfigMap), + "Failed to restore operator ConfigMap") + + // Wait for the deployment to revert to the original image + err = wait.PollUntilContextTimeout(TestEnv.Ctx, 10*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ + Namespace: distribution.Namespace, + Name: distribution.Name, + }, deployment) + if getErr != nil { + return false, getErr + } + currentImage := deployment.Spec.Template.Spec.Containers[0].Image + t.Logf("Current deployment image: %s (waiting for original: %s)", currentImage, originalImage) + return currentImage == originalImage, nil + }) + requireNoErrorWithDebugging(t, TestEnv, err, + "Deployment should revert to original image after removing override", + distribution.Namespace, distribution.Name) +}