Skip to content

Commit f4d1d86

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., `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 <derekh@redhat.com>
1 parent 8940cc2 commit f4d1d86

File tree

5 files changed

+399
-40
lines changed

5 files changed

+399
-40
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,35 @@ kubectl apply -f feature-flags.yaml
134134
Within the next reconciliation loop the operator will begin creating a `<name>-network-policy` resource for each distribution.
135135
Set `enabled: false` (or remove the block) to turn the feature back off; the operator will delete the previously managed policies.
136136

137+
## Image Mapping Overrides
138+
139+
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.
140+
141+
### Configuration
142+
143+
Create or update the operator ConfigMap with an `image-overrides` key:
144+
145+
```yaml
146+
147+
image-overrides: |
148+
starter-gpu: quay.io/custom/llama-stack:starter-gpu
149+
starter: quay.io/custom/llama-stack:starter
150+
```
151+
152+
### Configuration Format
153+
154+
Use the distribution name directly as the key (e.g., `starter-gpu`, `starter`). The operator will apply these overrides automatically
155+
156+
### Example Usage
157+
158+
To update the LLS Distribution image for all `starter` distributions:
159+
160+
```bash
161+
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"}}'
162+
```
163+
164+
This will cause all LlamaStackDistribution resources using the `starter` distribution to restart with the new image.
165+
137166
## Developer Guide
138167

139168
### Prerequisites

controllers/llamastackdistribution_controller.go

Lines changed: 100 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ type LlamaStackDistributionReconciler struct {
9292
Scheme *runtime.Scheme
9393
// Feature flags
9494
EnableNetworkPolicy bool
95+
// Image mapping overrides
96+
ImageMappingOverrides map[string]string
9597
// Cluster info
9698
ClusterInfo *cluster.ClusterInfo
9799
httpClient *http.Client
@@ -597,21 +599,40 @@ func (r *LlamaStackDistributionReconciler) configMapUpdatePredicate(e event.Upda
597599
return false
598600
}
599601

600-
// Parse the feature flags if the operator config ConfigMap has changed
602+
// Check if this is the operator config ConfigMap
603+
if r.handleOperatorConfigUpdate(newConfigMap) {
604+
return true
605+
}
606+
607+
// Handle referenced ConfigMap updates
608+
return r.handleReferencedConfigMapUpdate(oldConfigMap, newConfigMap)
609+
}
610+
611+
// handleOperatorConfigUpdate processes updates to the operator config ConfigMap.
612+
func (r *LlamaStackDistributionReconciler) handleOperatorConfigUpdate(configMap *corev1.ConfigMap) bool {
601613
operatorNamespace, err := deploy.GetOperatorNamespace()
602614
if err != nil {
603615
return false
604616
}
605-
if newConfigMap.Name == operatorConfigData && newConfigMap.Namespace == operatorNamespace {
606-
EnableNetworkPolicy, err := parseFeatureFlags(newConfigMap.Data)
607-
if err != nil {
608-
log.FromContext(context.Background()).Error(err, "Failed to parse feature flags")
609-
} else {
610-
r.EnableNetworkPolicy = EnableNetworkPolicy
611-
}
612-
return true
617+
618+
if configMap.Name != operatorConfigData || configMap.Namespace != operatorNamespace {
619+
return false
620+
}
621+
622+
// Update feature flags
623+
EnableNetworkPolicy, err := parseFeatureFlags(configMap.Data)
624+
if err != nil {
625+
log.FromContext(context.Background()).Error(err, "Failed to parse feature flags")
626+
} else {
627+
r.EnableNetworkPolicy = EnableNetworkPolicy
613628
}
614629

630+
r.ImageMappingOverrides = ParseImageMappingOverrides(configMap.Data)
631+
return true
632+
}
633+
634+
// handleReferencedConfigMapUpdate processes updates to referenced ConfigMaps.
635+
func (r *LlamaStackDistributionReconciler) handleReferencedConfigMapUpdate(oldConfigMap, newConfigMap *corev1.ConfigMap) bool {
615636
// Only proceed if this ConfigMap is referenced by any LlamaStackDistribution
616637
if !r.isConfigMapReferenced(newConfigMap) {
617638
return false
@@ -783,7 +804,7 @@ func (r *LlamaStackDistributionReconciler) findLlamaStackDistributionsForConfigM
783804

784805
operatorNamespace, err := deploy.GetOperatorNamespace()
785806
if err != nil {
786-
log.FromContext(context.Background()).Error(err, "Failed to get operator namespace for config map event processing")
807+
logger.Error(err, "Failed to get operator namespace for config map event processing")
787808
return nil
788809
}
789810
// If the operator config was changed, we reconcile all LlamaStackDistributions
@@ -1672,53 +1693,92 @@ func NewLlamaStackDistributionReconciler(ctx context.Context, client client.Clie
16721693
return nil, fmt.Errorf("failed to get operator namespace: %w", err)
16731694
}
16741695

1675-
// Get the ConfigMap
1676-
// If the ConfigMap doesn't exist, create it with default feature flags
1677-
// If the ConfigMap exists, parse the feature flags from the Configmap
1696+
// Initialize operator config ConfigMap
1697+
configMap, err := initializeOperatorConfigMap(ctx, client, operatorNamespace)
1698+
if err != nil {
1699+
return nil, err
1700+
}
1701+
1702+
// Parse feature flags from ConfigMap
1703+
enableNetworkPolicy, err := parseFeatureFlags(configMap.Data)
1704+
if err != nil {
1705+
return nil, fmt.Errorf("failed to parse feature flags: %w", err)
1706+
}
1707+
1708+
// Parse image mapping overrides from ConfigMap
1709+
imageMappingOverrides := ParseImageMappingOverrides(configMap.Data)
1710+
1711+
return &LlamaStackDistributionReconciler{
1712+
Client: client,
1713+
Scheme: scheme,
1714+
EnableNetworkPolicy: enableNetworkPolicy,
1715+
ImageMappingOverrides: imageMappingOverrides,
1716+
ClusterInfo: clusterInfo,
1717+
httpClient: &http.Client{Timeout: 5 * time.Second},
1718+
}, nil
1719+
}
1720+
1721+
// initializeOperatorConfigMap gets or creates the operator config ConfigMap.
1722+
func initializeOperatorConfigMap(ctx context.Context, c client.Client, operatorNamespace string) (*corev1.ConfigMap, error) {
16781723
configMap := &corev1.ConfigMap{}
16791724
configMapName := types.NamespacedName{
16801725
Name: operatorConfigData,
16811726
Namespace: operatorNamespace,
16821727
}
16831728

1684-
if err = client.Get(ctx, configMapName, configMap); err != nil {
1685-
if !k8serrors.IsNotFound(err) {
1686-
return nil, fmt.Errorf("failed to get ConfigMap: %w", err)
1687-
}
1729+
err := c.Get(ctx, configMapName, configMap)
1730+
if err == nil {
1731+
return configMap, nil
1732+
}
16881733

1689-
// ConfigMap doesn't exist, create it with defaults
1690-
configMap, err = createDefaultConfigMap(configMapName)
1691-
if err != nil {
1692-
return nil, fmt.Errorf("failed to generate default configMap: %w", err)
1734+
if !k8serrors.IsNotFound(err) {
1735+
return nil, fmt.Errorf("failed to get ConfigMap: %w", err)
1736+
}
1737+
1738+
// ConfigMap doesn't exist, create it with defaults
1739+
configMap, err = createDefaultConfigMap(configMapName)
1740+
if err != nil {
1741+
return nil, fmt.Errorf("failed to generate default configMap: %w", err)
1742+
}
1743+
1744+
if err = c.Create(ctx, configMap); err != nil {
1745+
return nil, fmt.Errorf("failed to create ConfigMap: %w", err)
1746+
}
1747+
1748+
return configMap, nil
1749+
}
1750+
1751+
func ParseImageMappingOverrides(configMapData map[string]string) map[string]string {
1752+
imageMappingOverrides := make(map[string]string)
1753+
1754+
// Look for the image-overrides key in the ConfigMap data
1755+
if overridesYAML, exists := configMapData["image-overrides"]; exists {
1756+
// Parse the YAML content
1757+
var overrides map[string]string
1758+
if err := yaml.Unmarshal([]byte(overridesYAML), &overrides); err != nil {
1759+
// Log error but continue with empty overrides
1760+
fmt.Printf("failed to parse image-overrides YAML: %v\n", err)
1761+
return imageMappingOverrides
16931762
}
16941763

1695-
if err = client.Create(ctx, configMap); err != nil {
1696-
return nil, fmt.Errorf("failed to create ConfigMap: %w", err)
1764+
// Copy the parsed overrides to our result map
1765+
for version, image := range overrides {
1766+
imageMappingOverrides[version] = image
16971767
}
16981768
}
16991769

1700-
// Parse feature flags from ConfigMap
1701-
enableNetworkPolicy, err := parseFeatureFlags(configMap.Data)
1702-
if err != nil {
1703-
return nil, fmt.Errorf("failed to parse feature flags: %w", err)
1704-
}
1705-
return &LlamaStackDistributionReconciler{
1706-
Client: client,
1707-
Scheme: scheme,
1708-
EnableNetworkPolicy: enableNetworkPolicy,
1709-
ClusterInfo: clusterInfo,
1710-
httpClient: &http.Client{Timeout: 5 * time.Second},
1711-
}, nil
1770+
return imageMappingOverrides
17121771
}
17131772

17141773
// NewTestReconciler creates a reconciler for testing, allowing injection of a custom http client and feature flags.
17151774
func NewTestReconciler(client client.Client, scheme *runtime.Scheme, clusterInfo *cluster.ClusterInfo,
17161775
httpClient *http.Client, enableNetworkPolicy bool) *LlamaStackDistributionReconciler {
17171776
return &LlamaStackDistributionReconciler{
1718-
Client: client,
1719-
Scheme: scheme,
1720-
ClusterInfo: clusterInfo,
1721-
httpClient: httpClient,
1722-
EnableNetworkPolicy: enableNetworkPolicy,
1777+
Client: client,
1778+
Scheme: scheme,
1779+
ClusterInfo: clusterInfo,
1780+
httpClient: httpClient,
1781+
EnableNetworkPolicy: enableNetworkPolicy,
1782+
ImageMappingOverrides: make(map[string]string),
17231783
}
17241784
}

controllers/llamastackdistribution_controller_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,3 +796,161 @@ InvalidCertificateDataThatIsNotValidX509
796796
"error should indicate X.509 parsing failure")
797797
})
798798
}
799+
800+
func TestParseImageMappingOverrides_SingleOverride(t *testing.T) {
801+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
802+
803+
// Test data with single override
804+
configMapData := map[string]string{
805+
"image-overrides": "starter: quay.io/custom/llama-stack:starter",
806+
}
807+
808+
// Call the function
809+
result := controllers.ParseImageMappingOverrides(configMapData)
810+
811+
// Assertions
812+
require.Len(t, result, 1, "Should have exactly one override")
813+
require.Equal(t, "quay.io/custom/llama-stack:starter", result["starter"], "Override should match expected value")
814+
}
815+
816+
func TestParseImageMappingOverrides_InvalidYAML(t *testing.T) {
817+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
818+
819+
// Test data with invalid YAML
820+
configMapData := map[string]string{
821+
"image-overrides": "invalid: yaml: content: [",
822+
}
823+
824+
// Call the function
825+
result := controllers.ParseImageMappingOverrides(configMapData)
826+
827+
// Assertions - should return empty map on error
828+
require.Empty(t, result, "Should return empty map when YAML is invalid")
829+
}
830+
831+
func TestNewLlamaStackDistributionReconciler_WithImageOverrides(t *testing.T) {
832+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
833+
834+
// Create operator namespace
835+
operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system")
836+
t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name)
837+
838+
// Create test ConfigMap with image overrides
839+
configMap := &corev1.ConfigMap{
840+
ObjectMeta: metav1.ObjectMeta{
841+
Name: "llama-stack-operator-config",
842+
Namespace: operatorNamespace.Name,
843+
},
844+
Data: map[string]string{
845+
"image-overrides": "starter: quay.io/custom/llama-stack:starter",
846+
"featureFlags": `enableNetworkPolicy:
847+
enabled: false`,
848+
},
849+
}
850+
require.NoError(t, k8sClient.Create(t.Context(), configMap))
851+
852+
// Create test cluster info
853+
clusterInfo := &cluster.ClusterInfo{
854+
OperatorNamespace: operatorNamespace.Name,
855+
DistributionImages: map[string]string{"starter": "default-image"},
856+
}
857+
858+
// Call the function
859+
reconciler, err := controllers.NewLlamaStackDistributionReconciler(
860+
t.Context(),
861+
k8sClient,
862+
scheme.Scheme,
863+
clusterInfo,
864+
)
865+
866+
// Assertions
867+
require.NoError(t, err, "Should create reconciler successfully")
868+
require.NotNil(t, reconciler, "Reconciler should not be nil")
869+
require.Len(t, reconciler.ImageMappingOverrides, 1, "Should have one image override")
870+
require.Equal(t, "quay.io/custom/llama-stack:starter",
871+
reconciler.ImageMappingOverrides["starter"], "Override should match expected value")
872+
require.False(t, reconciler.EnableNetworkPolicy, "Network policy should be disabled")
873+
}
874+
875+
func TestConfigMapUpdateTriggersReconciliation(t *testing.T) {
876+
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
877+
878+
// Create test namespace
879+
namespace := createTestNamespace(t, "test-configmap-update")
880+
operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system")
881+
t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name)
882+
883+
// Create initial ConfigMap
884+
configMap := &corev1.ConfigMap{
885+
ObjectMeta: metav1.ObjectMeta{
886+
Name: "llama-stack-operator-config",
887+
Namespace: operatorNamespace.Name,
888+
},
889+
Data: map[string]string{
890+
"featureFlags": `enableNetworkPolicy:
891+
enabled: false`,
892+
},
893+
}
894+
require.NoError(t, k8sClient.Create(t.Context(), configMap))
895+
896+
// Create LlamaStackDistribution instance using starter
897+
instance := NewDistributionBuilder().
898+
WithName("test-configmap-update").
899+
WithNamespace(namespace.Name).
900+
WithDistribution("starter").
901+
Build()
902+
require.NoError(t, k8sClient.Create(t.Context(), instance))
903+
904+
// Create reconciler with initial overrides
905+
clusterInfo := &cluster.ClusterInfo{
906+
OperatorNamespace: operatorNamespace.Name,
907+
DistributionImages: map[string]string{"starter": "default-starter-image"},
908+
}
909+
910+
reconciler, err := controllers.NewLlamaStackDistributionReconciler(
911+
t.Context(),
912+
k8sClient,
913+
scheme.Scheme,
914+
clusterInfo,
915+
)
916+
require.NoError(t, err)
917+
918+
// Initial reconciliation
919+
_, err = reconciler.Reconcile(t.Context(), ctrl.Request{
920+
NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace},
921+
})
922+
require.NoError(t, err)
923+
924+
// Get initial deployment and verify it uses the first override
925+
deployment := &appsv1.Deployment{}
926+
waitForResource(t, k8sClient, instance.Namespace, instance.Name, deployment)
927+
initialImage := deployment.Spec.Template.Spec.Containers[0].Image
928+
require.Equal(t, "default-starter-image", initialImage,
929+
"Initial deployment should use distribution image")
930+
931+
// Update ConfigMap with new overrides
932+
configMap.Data["image-overrides"] = "starter: quay.io/custom/llama-stack:starter"
933+
require.NoError(t, k8sClient.Update(t.Context(), configMap))
934+
935+
// Simulate ConfigMap update by recreating reconciler (in real scenario this would be triggered by watch)
936+
updatedReconciler, err := controllers.NewLlamaStackDistributionReconciler(
937+
t.Context(),
938+
k8sClient,
939+
scheme.Scheme,
940+
clusterInfo,
941+
)
942+
require.NoError(t, err)
943+
944+
// Reconcile with updated overrides
945+
_, err = updatedReconciler.Reconcile(t.Context(), ctrl.Request{
946+
NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace},
947+
})
948+
require.NoError(t, err)
949+
950+
// Verify deployment was updated with new image
951+
waitForResourceWithKeyAndCondition(
952+
t, k8sClient, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace},
953+
deployment, func() bool {
954+
return deployment.Spec.Template.Spec.Containers[0].Image == "quay.io/custom/llama-stack:starter"
955+
}, "Deployment should be updated with new image")
956+
}

0 commit comments

Comments
 (0)