diff --git a/api/v1alpha1/llamastackdistribution_types.go b/api/v1alpha1/llamastackdistribution_types.go index ebc91f5e2..56c934a2d 100644 --- a/api/v1alpha1/llamastackdistribution_types.go +++ b/api/v1alpha1/llamastackdistribution_types.go @@ -85,6 +85,9 @@ type ServerSpec struct { // TLSConfig defines the TLS configuration for the llama-stack server // +optional TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` + // EnvFromExternalConfigMaps defines external ConfigMaps to inject as environment variables + // +optional + EnvFromExternalConfigMaps []ExternalConfigMapSpec `json:"envFromExternalConfigMaps,omitempty"` } type UserConfigSpec struct { @@ -119,6 +122,18 @@ type CABundleConfig struct { ConfigMapKeys []string `json:"configMapKeys,omitempty"` } +// ExternalConfigMapSpec defines external ConfigMaps to inject as environment variables +type ExternalConfigMapSpec struct { + // Name is the name of the ConfigMap + Name string `json:"name"` + // Namespace is the namespace of the ConfigMap + Namespace string `json:"namespace"` + // Mapping defines how ConfigMap keys map to environment variable names + // Key is the ConfigMap key, Value is the environment variable name + // +optional + Mapping map[string]string `json:"mapping,omitempty"` +} + // StorageSpec defines the persistent storage configuration type StorageSpec struct { // Size is the size of the persistent volume claim created for holding persistent data of the llama-stack server diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b39a399e7..2d212aed1 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -123,6 +123,28 @@ func (in *DistributionType) DeepCopy() *DistributionType { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalConfigMapSpec) DeepCopyInto(out *ExternalConfigMapSpec) { + *out = *in + if in.Mapping != nil { + in, out := &in.Mapping, &out.Mapping + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalConfigMapSpec. +func (in *ExternalConfigMapSpec) DeepCopy() *ExternalConfigMapSpec { + if in == nil { + return nil + } + out := new(ExternalConfigMapSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LlamaStackDistribution) DeepCopyInto(out *LlamaStackDistribution) { *out = *in @@ -308,6 +330,13 @@ func (in *ServerSpec) DeepCopyInto(out *ServerSpec) { *out = new(TLSConfig) (*in).DeepCopyInto(*out) } + if in.EnvFromExternalConfigMaps != nil { + in, out := &in.EnvFromExternalConfigMaps, &out.EnvFromExternalConfigMaps + *out = make([]ExternalConfigMapSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerSpec. diff --git a/config/crd/bases/llamastack.io_llamastackdistributions.yaml b/config/crd/bases/llamastack.io_llamastackdistributions.yaml index 179808c1c..e9a238abc 100644 --- a/config/crd/bases/llamastack.io_llamastackdistributions.yaml +++ b/config/crd/bases/llamastack.io_llamastackdistributions.yaml @@ -263,6 +263,31 @@ spec: x-kubernetes-validations: - message: Only one of name or image can be specified rule: '!(has(self.name) && has(self.image))' + envFromExternalConfigMaps: + description: EnvFromExternalConfigMaps defines external ConfigMaps + to inject as environment variables + items: + description: ExternalConfigMapSpec defines external ConfigMaps + to inject as environment variables + properties: + mapping: + additionalProperties: + type: string + description: |- + Mapping defines how ConfigMap keys map to environment variable names + Key is the ConfigMap key, Value is the environment variable name + type: object + name: + description: Name is the name of the ConfigMap + type: string + namespace: + description: Namespace is the namespace of the ConfigMap + type: string + required: + - name + - namespace + type: object + type: array podOverrides: description: PodOverrides allows advanced pod-level customization. properties: diff --git a/config/samples/llamastackdistribution-with-external-configmaps.yaml b/config/samples/llamastackdistribution-with-external-configmaps.yaml new file mode 100644 index 000000000..0b0220cc1 --- /dev/null +++ b/config/samples/llamastackdistribution-with-external-configmaps.yaml @@ -0,0 +1,41 @@ +apiVersion: llamastack.io/v1alpha1 +kind: LlamaStackDistribution +metadata: + name: llamastackdistribution-with-external-configmaps +spec: + replicas: 1 + server: + containerSpec: + env: + - name: INFERENCE_MODEL + value: 'llama3.2:1b' + - name: OLLAMA_URL + value: 'http://ollama-server-service.ollama-dist.svc.cluster.local:11434' + name: llama-stack + distribution: + name: starter + storage: + size: "20Gi" + mountPath: "/home/lls/.lls" + # External ConfigMaps configuration - enables referencing Konflux-built images + # from the redhat-ods-applications namespace ConfigMaps + envFromExternalConfigMaps: + - name: trustyai-service-operator-config + namespace: redhat-ods-applications + mapping: + ragas-provider-image: RAGAS_PROVIDER_IMAGE + garak-provider-image: GARAK_PROVIDER_IMAGE +--- +# Example of how the ConfigMap would look like in redhat-ods-applications namespace +# This would be automatically created/managed by the trustyai-service-operator +apiVersion: v1 +kind: ConfigMap +metadata: + name: trustyai-service-operator-config + namespace: redhat-ods-applications +data: + ragas-provider-image: "quay.io/trustyai/llama-stack-provider-ragas:v1.2.3-konflux-build-123" + garak-provider-image: "quay.io/trustyai/llama-stack-provider-trustyai-garak:v1.2.3-konflux-build-456" + # Other trustyai operator configuration values... + trustyaiServiceImage: "quay.io/trustyai/trustyai-service:latest" + trustyaiOperatorImage: "quay.io/trustyai/trustyai-service-operator:latest" diff --git a/controllers/resource_helper.go b/controllers/resource_helper.go index d3d5f1aef..24c20d298 100644 --- a/controllers/resource_helper.go +++ b/controllers/resource_helper.go @@ -25,6 +25,8 @@ import ( llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/log" @@ -186,6 +188,13 @@ func configureContainerEnvironment(ctx context.Context, r *LlamaStackDistributio } } + // Add environment variables from external ConfigMaps + if err := addExternalConfigMapEnvVars(ctx, r, instance, container); err != nil { + // Log error but don't fail deployment - allows graceful degradation + logger := log.FromContext(ctx) + logger.Error(err, "Failed to add external ConfigMap environment variables") + } + // Finally, add the user provided env vars container.Env = append(container.Env, instance.Spec.Server.ContainerSpec.Env...) } @@ -662,3 +671,52 @@ func (r *LlamaStackDistributionReconciler) resolveImage(distribution llamav1alph return "", errors.New("failed to validate distribution: either distribution.name or distribution.image must be set") } } + +// addExternalConfigMapEnvVars adds environment variables from external ConfigMaps. +func addExternalConfigMapEnvVars(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution, container *corev1.Container) error { + if len(instance.Spec.Server.EnvFromExternalConfigMaps) == 0 { + return nil + } + + logger := log.FromContext(ctx) + + for _, extConfigMap := range instance.Spec.Server.EnvFromExternalConfigMaps { + configMap := &corev1.ConfigMap{} + err := r.Get(ctx, types.NamespacedName{ + Name: extConfigMap.Name, + Namespace: extConfigMap.Namespace, + }, configMap) + + if err != nil { + if k8serrors.IsNotFound(err) { + logger.Info("External ConfigMap not found, skipping", + "configMapName", extConfigMap.Name, + "configMapNamespace", extConfigMap.Namespace) + continue + } + return fmt.Errorf("failed to get external ConfigMap %s/%s: %w", + extConfigMap.Namespace, extConfigMap.Name, err) + } + + for configMapKey, envVarName := range extConfigMap.Mapping { + if value, exists := configMap.Data[configMapKey]; exists { + container.Env = append(container.Env, corev1.EnvVar{ + Name: envVarName, + Value: value, + }) + logger.V(1).Info("Added environment variable from external ConfigMap", + "configMapName", extConfigMap.Name, + "configMapNamespace", extConfigMap.Namespace, + "configMapKey", configMapKey, + "envVarName", envVarName) + } else { + logger.Info("ConfigMap key not found, skipping", + "configMapName", extConfigMap.Name, + "configMapNamespace", extConfigMap.Namespace, + "configMapKey", configMapKey) + } + } + } + + return nil +} diff --git a/controllers/resource_helper_test.go b/controllers/resource_helper_test.go index f1ea8dd98..846508c56 100644 --- a/controllers/resource_helper_test.go +++ b/controllers/resource_helper_test.go @@ -28,7 +28,10 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func TestBuildContainerSpec(t *testing.T) { @@ -664,3 +667,146 @@ func newDefaultStartupProbe(port int32) *corev1.Probe { SuccessThreshold: startupProbeSuccessThreshold, } } + +func TestAddExternalConfigMapEnvVars(t *testing.T) { + testCases := []struct { + name string + instance *llamav1alpha1.LlamaStackDistribution + existingConfigMaps []corev1.ConfigMap + expectedEnvVars []corev1.EnvVar + expectError bool + }{ + { + name: "no external configmaps configured", + instance: &llamav1alpha1.LlamaStackDistribution{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test-ns", + }, + Spec: llamav1alpha1.LlamaStackDistributionSpec{ + Server: llamav1alpha1.ServerSpec{ + EnvFromExternalConfigMaps: nil, + }, + }, + }, + existingConfigMaps: []corev1.ConfigMap{}, + expectedEnvVars: []corev1.EnvVar{}, + expectError: false, + }, + { + name: "single external configmap with mapping", + instance: &llamav1alpha1.LlamaStackDistribution{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test-ns", + }, + Spec: llamav1alpha1.LlamaStackDistributionSpec{ + Server: llamav1alpha1.ServerSpec{ + EnvFromExternalConfigMaps: []llamav1alpha1.ExternalConfigMapSpec{ + { + Name: "trustyai-config", + Namespace: "redhat-ods-applications", + Mapping: map[string]string{ + "ragas-provider-image": "RAGAS_PROVIDER_IMAGE", + "garak-provider-image": "GARAK_PROVIDER_IMAGE", + }, + }, + }, + }, + }, + }, + existingConfigMaps: []corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "trustyai-config", + Namespace: "redhat-ods-applications", + }, + Data: map[string]string{ + "ragas-provider-image": "quay.io/trustyai/ragas:v1.2.3-konflux-abc123", + "garak-provider-image": "quay.io/trustyai/garak:v1.2.3-konflux-def456", + "other-config": "should-be-ignored", + }, + }, + }, + expectedEnvVars: []corev1.EnvVar{ + {Name: "RAGAS_PROVIDER_IMAGE", Value: "quay.io/trustyai/ragas:v1.2.3-konflux-abc123"}, + {Name: "GARAK_PROVIDER_IMAGE", Value: "quay.io/trustyai/garak:v1.2.3-konflux-def456"}, + }, + expectError: false, + }, + { + name: "configmap not found - should not error but skip", + instance: &llamav1alpha1.LlamaStackDistribution{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test-ns", + }, + Spec: llamav1alpha1.LlamaStackDistributionSpec{ + Server: llamav1alpha1.ServerSpec{ + EnvFromExternalConfigMaps: []llamav1alpha1.ExternalConfigMapSpec{ + { + Name: "missing-config", + Namespace: "redhat-ods-applications", + Mapping: map[string]string{ + "some-key": "SOME_ENV", + }, + }, + }, + }, + }, + }, + existingConfigMaps: []corev1.ConfigMap{}, + expectedEnvVars: []corev1.EnvVar{}, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, llamav1alpha1.AddToScheme(scheme)) + + objs := make([]client.Object, len(tc.existingConfigMaps)) + for i := range tc.existingConfigMaps { + objs[i] = &tc.existingConfigMaps[i] + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() + + reconciler := &LlamaStackDistributionReconciler{ + Client: fakeClient, + } + + container := &corev1.Container{ + Name: "test-container", + Image: "test-image", + Env: []corev1.EnvVar{}, // Start with empty env vars + } + + err := addExternalConfigMapEnvVars(t.Context(), reconciler, tc.instance, container) + + if tc.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + + for _, expectedEnv := range tc.expectedEnvVars { + found := false + for _, actualEnv := range container.Env { + if actualEnv.Name == expectedEnv.Name && actualEnv.Value == expectedEnv.Value { + found = true + break + } + } + assert.True(t, found, "Expected env var %s=%s not found in container env vars", expectedEnv.Name, expectedEnv.Value) + } + + assert.Len(t, container.Env, len(tc.expectedEnvVars), "Unexpected number of env vars added") + } + }) + } +} diff --git a/docs/api-overview.md b/docs/api-overview.md index b3a70e5bf..a6c42f0ac 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -85,6 +85,19 @@ _Appears in:_ | `name` _string_ | Name is the distribution name that maps to supported distributions. | | | | `image` _string_ | Image is the direct container image reference to use | | | +#### ExternalConfigMapSpec + +ExternalConfigMapSpec defines external ConfigMaps to inject as environment variables + +_Appears in:_ +- [ServerSpec](#serverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the ConfigMap | | | +| `namespace` _string_ | Namespace is the namespace of the ConfigMap | | | +| `mapping` _object (keys:string, values:string)_ | Mapping defines how ConfigMap keys map to environment variable names
Key is the ConfigMap key, Value is the environment variable name | | | + #### LlamaStackDistribution _Appears in:_ @@ -196,6 +209,7 @@ _Appears in:_ | `storage` _[StorageSpec](#storagespec)_ | Storage defines the persistent storage configuration | | | | `userConfig` _[UserConfigSpec](#userconfigspec)_ | UserConfig defines the user configuration for the llama-stack server | | | | `tlsConfig` _[TLSConfig](#tlsconfig)_ | TLSConfig defines the TLS configuration for the llama-stack server | | | +| `envFromExternalConfigMaps` _[ExternalConfigMapSpec](#externalconfigmapspec) array_ | EnvFromExternalConfigMaps defines external ConfigMaps to inject as environment variables | | | #### StorageSpec diff --git a/go.mod b/go.mod index 7b0678b1c..a133ba3e2 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.8.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-errors/errors v1.4.2 // indirect diff --git a/release/operator.yaml b/release/operator.yaml index ec24adf6e..2de0e52f9 100644 --- a/release/operator.yaml +++ b/release/operator.yaml @@ -272,6 +272,31 @@ spec: x-kubernetes-validations: - message: Only one of name or image can be specified rule: '!(has(self.name) && has(self.image))' + envFromExternalConfigMaps: + description: EnvFromExternalConfigMaps defines external ConfigMaps + to inject as environment variables + items: + description: ExternalConfigMapSpec defines external ConfigMaps + to inject as environment variables + properties: + mapping: + additionalProperties: + type: string + description: |- + Mapping defines how ConfigMap keys map to environment variable names + Key is the ConfigMap key, Value is the environment variable name + type: object + name: + description: Name is the name of the ConfigMap + type: string + namespace: + description: Namespace is the namespace of the ConfigMap + type: string + required: + - name + - namespace + type: object + type: array podOverrides: description: PodOverrides allows advanced pod-level customization. properties: