Skip to content

Commit 64745bf

Browse files
committed
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., rh-dev) 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 <derekh@redhat.com>
1 parent abf87e5 commit 64745bf

File tree

4 files changed

+305
-39
lines changed

4 files changed

+305
-39
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,48 @@ Example to create a run.yaml ConfigMap, and a LlamaStackDistribution that refere
104104
kubectl apply -f config/samples/example-with-configmap.yaml
105105
```
106106

107+
## Image Mapping Overrides
108+
109+
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.
110+
111+
### Configuration
112+
113+
Create or update the operator ConfigMap with an `image-overrides` key:
114+
115+
```yaml
116+
apiVersion: v1
117+
kind: ConfigMap
118+
metadata:
119+
name: llama-stack-operator-config
120+
namespace: llama-stack-k8s-operator-system
121+
data:
122+
image-overrides: |
123+
rh-dev: quay.io/rhoai/rhoai-fbc-fragment:rhoai-2.25@sha256:3bc98555
124+
starter: quay.io/custom/llama-stack:starter
125+
```
126+
127+
### Configuration Format
128+
129+
Use the distribution name directly as the key (e.g., `rh-dev`, `starter`). The operator will apply these overrides automatically
130+
131+
### How It Works
132+
133+
1. The operator reads image overrides from the `image-overrides` key in the operator ConfigMap
134+
2. Overrides are parsed as YAML with distribution-name-to-image mappings
135+
3. When deploying a LlamaStackDistribution, the operator checks for overrides matching the distribution name
136+
4. If an override exists, it uses the specified image instead of the default distribution image
137+
5. Changes to the ConfigMap automatically trigger reconciliation of all LlamaStackDistribution resources
138+
139+
### Example Usage
140+
141+
To update the LLS Distribution image for all `rh-dev` distributions:
142+
143+
```bash
144+
kubectl patch configmap llama-stack-operator-config -n llama-stack-k8s-operator-system --type merge -p '{"data":{"image-overrides":"rh-dev: quay.io/opendatahub/llama-stack:latest"}}'
145+
```
146+
147+
This will cause all LlamaStackDistribution resources using the `rh-dev` distribution to restart with the new image.
148+
107149
## Developer Guide
108150

109151
### Prerequisites

controllers/llamastackdistribution_controller.go

Lines changed: 99 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ type LlamaStackDistributionReconciler struct {
8888
Scheme *runtime.Scheme
8989
// Feature flags
9090
EnableNetworkPolicy bool
91+
// Image mapping overrides
92+
ImageMappingOverrides map[string]string
9193
// Cluster info
9294
ClusterInfo *cluster.ClusterInfo
9395
httpClient *http.Client
@@ -536,21 +538,40 @@ func (r *LlamaStackDistributionReconciler) configMapUpdatePredicate(e event.Upda
536538
return false
537539
}
538540

539-
// Parse the feature flags if the operator config ConfigMap has changed
541+
// Check if this is the operator config ConfigMap
542+
if r.handleOperatorConfigUpdate(newConfigMap) {
543+
return true
544+
}
545+
546+
// Handle referenced ConfigMap updates
547+
return r.handleReferencedConfigMapUpdate(oldConfigMap, newConfigMap)
548+
}
549+
550+
// handleOperatorConfigUpdate processes updates to the operator config ConfigMap.
551+
func (r *LlamaStackDistributionReconciler) handleOperatorConfigUpdate(configMap *corev1.ConfigMap) bool {
540552
operatorNamespace, err := deploy.GetOperatorNamespace()
541553
if err != nil {
542554
return false
543555
}
544-
if newConfigMap.Name == operatorConfigData && newConfigMap.Namespace == operatorNamespace {
545-
EnableNetworkPolicy, err := parseFeatureFlags(newConfigMap.Data)
546-
if err != nil {
547-
log.FromContext(context.Background()).Error(err, "Failed to parse feature flags")
548-
} else {
549-
r.EnableNetworkPolicy = EnableNetworkPolicy
550-
}
551-
return true
556+
557+
if configMap.Name != operatorConfigData || configMap.Namespace != operatorNamespace {
558+
return false
559+
}
560+
561+
// Update feature flags
562+
EnableNetworkPolicy, err := parseFeatureFlags(configMap.Data)
563+
if err != nil {
564+
log.FromContext(context.Background()).Error(err, "Failed to parse feature flags")
565+
} else {
566+
r.EnableNetworkPolicy = EnableNetworkPolicy
552567
}
553568

569+
r.ImageMappingOverrides = ParseImageMappingOverrides(configMap.Data)
570+
return true
571+
}
572+
573+
// handleReferencedConfigMapUpdate processes updates to referenced ConfigMaps.
574+
func (r *LlamaStackDistributionReconciler) handleReferencedConfigMapUpdate(oldConfigMap, newConfigMap *corev1.ConfigMap) bool {
554575
// Only proceed if this ConfigMap is referenced by any LlamaStackDistribution
555576
if !r.isConfigMapReferenced(newConfigMap) {
556577
return false
@@ -1360,53 +1381,92 @@ func NewLlamaStackDistributionReconciler(ctx context.Context, client client.Clie
13601381
return nil, fmt.Errorf("failed to get operator namespace: %w", err)
13611382
}
13621383

1363-
// Get the ConfigMap
1364-
// If the ConfigMap doesn't exist, create it with default feature flags
1365-
// If the ConfigMap exists, parse the feature flags from the Configmap
1384+
// Initialize operator config ConfigMap
1385+
configMap, err := initializeOperatorConfigMap(ctx, client, operatorNamespace)
1386+
if err != nil {
1387+
return nil, err
1388+
}
1389+
1390+
// Parse feature flags from ConfigMap
1391+
enableNetworkPolicy, err := parseFeatureFlags(configMap.Data)
1392+
if err != nil {
1393+
return nil, fmt.Errorf("failed to parse feature flags: %w", err)
1394+
}
1395+
1396+
// Parse image mapping overrides from ConfigMap
1397+
imageMappingOverrides := ParseImageMappingOverrides(configMap.Data)
1398+
1399+
return &LlamaStackDistributionReconciler{
1400+
Client: client,
1401+
Scheme: scheme,
1402+
EnableNetworkPolicy: enableNetworkPolicy,
1403+
ImageMappingOverrides: imageMappingOverrides,
1404+
ClusterInfo: clusterInfo,
1405+
httpClient: &http.Client{Timeout: 5 * time.Second},
1406+
}, nil
1407+
}
1408+
1409+
// initializeOperatorConfigMap gets or creates the operator config ConfigMap.
1410+
func initializeOperatorConfigMap(ctx context.Context, c client.Client, operatorNamespace string) (*corev1.ConfigMap, error) {
13661411
configMap := &corev1.ConfigMap{}
13671412
configMapName := types.NamespacedName{
13681413
Name: operatorConfigData,
13691414
Namespace: operatorNamespace,
13701415
}
13711416

1372-
if err = client.Get(ctx, configMapName, configMap); err != nil {
1373-
if !k8serrors.IsNotFound(err) {
1374-
return nil, fmt.Errorf("failed to get ConfigMap: %w", err)
1375-
}
1417+
err := c.Get(ctx, configMapName, configMap)
1418+
if err == nil {
1419+
return configMap, nil
1420+
}
13761421

1377-
// ConfigMap doesn't exist, create it with defaults
1378-
configMap, err = createDefaultConfigMap(configMapName)
1379-
if err != nil {
1380-
return nil, fmt.Errorf("failed to generate default configMap: %w", err)
1422+
if !k8serrors.IsNotFound(err) {
1423+
return nil, fmt.Errorf("failed to get ConfigMap: %w", err)
1424+
}
1425+
1426+
// ConfigMap doesn't exist, create it with defaults
1427+
configMap, err = createDefaultConfigMap(configMapName)
1428+
if err != nil {
1429+
return nil, fmt.Errorf("failed to generate default configMap: %w", err)
1430+
}
1431+
1432+
if err = c.Create(ctx, configMap); err != nil {
1433+
return nil, fmt.Errorf("failed to create ConfigMap: %w", err)
1434+
}
1435+
1436+
return configMap, nil
1437+
}
1438+
1439+
func ParseImageMappingOverrides(configMapData map[string]string) map[string]string {
1440+
imageMappingOverrides := make(map[string]string)
1441+
1442+
// Look for the image-overrides key in the ConfigMap data
1443+
if overridesYAML, exists := configMapData["image-overrides"]; exists {
1444+
// Parse the YAML content
1445+
var overrides map[string]string
1446+
if err := yaml.Unmarshal([]byte(overridesYAML), &overrides); err != nil {
1447+
// Log error but continue with empty overrides
1448+
fmt.Printf("failed to parse image-overrides YAML: %v\n", err)
1449+
return imageMappingOverrides
13811450
}
13821451

1383-
if err = client.Create(ctx, configMap); err != nil {
1384-
return nil, fmt.Errorf("failed to create ConfigMap: %w", err)
1452+
// Copy the parsed overrides to our result map
1453+
for version, image := range overrides {
1454+
imageMappingOverrides[version] = image
13851455
}
13861456
}
13871457

1388-
// Parse feature flags from ConfigMap
1389-
enableNetworkPolicy, err := parseFeatureFlags(configMap.Data)
1390-
if err != nil {
1391-
return nil, fmt.Errorf("failed to parse feature flags: %w", err)
1392-
}
1393-
return &LlamaStackDistributionReconciler{
1394-
Client: client,
1395-
Scheme: scheme,
1396-
EnableNetworkPolicy: enableNetworkPolicy,
1397-
ClusterInfo: clusterInfo,
1398-
httpClient: &http.Client{Timeout: 5 * time.Second},
1399-
}, nil
1458+
return imageMappingOverrides
14001459
}
14011460

14021461
// NewTestReconciler creates a reconciler for testing, allowing injection of a custom http client and feature flags.
14031462
func NewTestReconciler(client client.Client, scheme *runtime.Scheme, clusterInfo *cluster.ClusterInfo,
14041463
httpClient *http.Client, enableNetworkPolicy bool) *LlamaStackDistributionReconciler {
14051464
return &LlamaStackDistributionReconciler{
1406-
Client: client,
1407-
Scheme: scheme,
1408-
ClusterInfo: clusterInfo,
1409-
httpClient: httpClient,
1410-
EnableNetworkPolicy: enableNetworkPolicy,
1465+
Client: client,
1466+
Scheme: scheme,
1467+
ClusterInfo: clusterInfo,
1468+
httpClient: httpClient,
1469+
EnableNetworkPolicy: enableNetworkPolicy,
1470+
ImageMappingOverrides: make(map[string]string),
14111471
}
14121472
}

controllers/llamastackdistribution_controller_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,161 @@ func TestNetworkPolicyConfiguration(t *testing.T) {
512512
})
513513
}
514514
}
515+
516+
func TestParseImageMappingOverrides_SingleOverride(t *testing.T) {
517+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
518+
519+
// Test data with single override
520+
configMapData := map[string]string{
521+
"image-overrides": "rh-dev: quay.io/rhoai/rhoai-fbc-fragment:rhoai-2.25@sha256:3bc98555",
522+
}
523+
524+
// Call the function
525+
result := controllers.ParseImageMappingOverrides(configMapData)
526+
527+
// Assertions
528+
require.Len(t, result, 1, "Should have exactly one override")
529+
require.Equal(t, "quay.io/rhoai/rhoai-fbc-fragment:rhoai-2.25@sha256:3bc98555", result["rh-dev"], "Override should match expected value")
530+
}
531+
532+
func TestParseImageMappingOverrides_InvalidYAML(t *testing.T) {
533+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
534+
535+
// Test data with invalid YAML
536+
configMapData := map[string]string{
537+
"image-overrides": "invalid: yaml: content: [",
538+
}
539+
540+
// Call the function
541+
result := controllers.ParseImageMappingOverrides(configMapData)
542+
543+
// Assertions - should return empty map on error
544+
require.Empty(t, result, "Should return empty map when YAML is invalid")
545+
}
546+
547+
func TestNewLlamaStackDistributionReconciler_WithImageOverrides(t *testing.T) {
548+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
549+
550+
// Create operator namespace
551+
operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system")
552+
t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name)
553+
554+
// Create test ConfigMap with image overrides
555+
configMap := &corev1.ConfigMap{
556+
ObjectMeta: metav1.ObjectMeta{
557+
Name: "llama-stack-operator-config",
558+
Namespace: operatorNamespace.Name,
559+
},
560+
Data: map[string]string{
561+
"image-overrides": "rh-dev: quay.io/rhoai/rhoai-fbc-fragment:rhoai-2.25@sha256:3bc98555",
562+
"featureFlags": `enableNetworkPolicy:
563+
enabled: false`,
564+
},
565+
}
566+
require.NoError(t, k8sClient.Create(t.Context(), configMap))
567+
568+
// Create test cluster info
569+
clusterInfo := &cluster.ClusterInfo{
570+
OperatorNamespace: operatorNamespace.Name,
571+
DistributionImages: map[string]string{"starter": "default-image"},
572+
}
573+
574+
// Call the function
575+
reconciler, err := controllers.NewLlamaStackDistributionReconciler(
576+
t.Context(),
577+
k8sClient,
578+
scheme.Scheme,
579+
clusterInfo,
580+
)
581+
582+
// Assertions
583+
require.NoError(t, err, "Should create reconciler successfully")
584+
require.NotNil(t, reconciler, "Reconciler should not be nil")
585+
require.Len(t, reconciler.ImageMappingOverrides, 1, "Should have one image override")
586+
require.Equal(t, "quay.io/rhoai/rhoai-fbc-fragment:rhoai-2.25@sha256:3bc98555",
587+
reconciler.ImageMappingOverrides["rh-dev"], "Override should match expected value")
588+
require.False(t, reconciler.EnableNetworkPolicy, "Network policy should be disabled")
589+
}
590+
591+
func TestConfigMapUpdateTriggersReconciliation(t *testing.T) {
592+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
593+
594+
// Create test namespace
595+
namespace := createTestNamespace(t, "test-configmap-update")
596+
operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system")
597+
t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name)
598+
599+
// Create initial ConfigMap
600+
configMap := &corev1.ConfigMap{
601+
ObjectMeta: metav1.ObjectMeta{
602+
Name: "llama-stack-operator-config",
603+
Namespace: operatorNamespace.Name,
604+
},
605+
Data: map[string]string{
606+
"featureFlags": `enableNetworkPolicy:
607+
enabled: false`,
608+
},
609+
}
610+
require.NoError(t, k8sClient.Create(t.Context(), configMap))
611+
612+
// Create LlamaStackDistribution instance using rh-dev
613+
instance := NewDistributionBuilder().
614+
WithName("test-configmap-update").
615+
WithNamespace(namespace.Name).
616+
WithDistribution("rh-dev").
617+
Build()
618+
require.NoError(t, k8sClient.Create(t.Context(), instance))
619+
620+
// Create reconciler with initial overrides
621+
clusterInfo := &cluster.ClusterInfo{
622+
OperatorNamespace: operatorNamespace.Name,
623+
DistributionImages: map[string]string{"rh-dev": "default-rh-dev-image"},
624+
}
625+
626+
reconciler, err := controllers.NewLlamaStackDistributionReconciler(
627+
t.Context(),
628+
k8sClient,
629+
scheme.Scheme,
630+
clusterInfo,
631+
)
632+
require.NoError(t, err)
633+
634+
// Initial reconciliation
635+
_, err = reconciler.Reconcile(t.Context(), ctrl.Request{
636+
NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace},
637+
})
638+
require.NoError(t, err)
639+
640+
// Get initial deployment and verify it uses the first override
641+
deployment := &appsv1.Deployment{}
642+
waitForResource(t, k8sClient, instance.Namespace, instance.Name, deployment)
643+
initialImage := deployment.Spec.Template.Spec.Containers[0].Image
644+
require.Equal(t, "default-rh-dev-image", initialImage,
645+
"Initial deployment should use distribution image")
646+
647+
// Update ConfigMap with new overrides
648+
configMap.Data["image-overrides"] = "rh-dev: quay.io/rhoai/rhoai-fbc-fragment:rhoai-2.25@sha256:newhash"
649+
require.NoError(t, k8sClient.Update(t.Context(), configMap))
650+
651+
// Simulate ConfigMap update by recreating reconciler (in real scenario this would be triggered by watch)
652+
updatedReconciler, err := controllers.NewLlamaStackDistributionReconciler(
653+
t.Context(),
654+
k8sClient,
655+
scheme.Scheme,
656+
clusterInfo,
657+
)
658+
require.NoError(t, err)
659+
660+
// Reconcile with updated overrides
661+
_, err = updatedReconciler.Reconcile(t.Context(), ctrl.Request{
662+
NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace},
663+
})
664+
require.NoError(t, err)
665+
666+
// Verify deployment was updated with new image
667+
waitForResourceWithKeyAndCondition(
668+
t, k8sClient, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace},
669+
deployment, func() bool {
670+
return deployment.Spec.Template.Spec.Containers[0].Image == "quay.io/rhoai/rhoai-fbc-fragment:rhoai-2.25@sha256:newhash"
671+
}, "Deployment should be updated with new image")
672+
}

0 commit comments

Comments
 (0)