diff --git a/filterconfig/filterconfig.go b/filterconfig/filterconfig.go index 92c0db6a..172f7cdc 100644 --- a/filterconfig/filterconfig.go +++ b/filterconfig/filterconfig.go @@ -161,12 +161,19 @@ type Backend struct { // BackendAuth ... TODO: refactor after https://github.com/envoyproxy/ai-gateway/pull/43. type BackendAuth struct { - AWSAuth *AWSAuth `json:"aws,omitempty"` + // APIKey is a location of the api key secret file. + APIKey *APIKeyAuth `json:"apiKey,omitempty"` + AWSAuth *AWSAuth `json:"aws,omitempty"` } // AWSAuth ... TODO: refactor after https://github.com/envoyproxy/ai-gateway/pull/43. type AWSAuth struct{} +// APIKeyAuth defines the file that will be mounted to the external proc. +type APIKeyAuth struct { + Filename string `json:"filename"` +} + // UnmarshalConfigYaml reads the file at the given path and unmarshals it into a Config struct. func UnmarshalConfigYaml(path string) (*Config, error) { raw, err := os.ReadFile(path) diff --git a/internal/controller/ai_gateway_route.go b/internal/controller/ai_gateway_route.go index 3117d78f..29ddc5a5 100644 --- a/internal/controller/ai_gateway_route.go +++ b/internal/controller/ai_gateway_route.go @@ -10,7 +10,6 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -43,24 +42,22 @@ func aiGatewayRouteIndexFunc(o client.Object) []string { // // This handles the AIGatewayRoute resource and creates the necessary resources for the external process. type aiGatewayRouteController struct { - client client.Client - kube kubernetes.Interface - logger logr.Logger - defaultExtProcImage string - eventChan chan ConfigSinkEvent + client client.Client + kube kubernetes.Interface + logger logr.Logger + eventChan chan ConfigSinkEvent } // NewAIGatewayRouteController creates a new reconcile.TypedReconciler[reconcile.Request] for the AIGatewayRoute resource. func NewAIGatewayRouteController( client client.Client, kube kubernetes.Interface, logger logr.Logger, - options Options, ch chan ConfigSinkEvent, + ch chan ConfigSinkEvent, ) reconcile.TypedReconciler[reconcile.Request] { return &aiGatewayRouteController{ - client: client, - kube: kube, - logger: logger.WithName("ai-eg-route-controller"), - defaultExtProcImage: options.ExtProcImage, - eventChan: ch, + client: client, + kube: kube, + logger: logger.WithName("ai-gateway-route-controller"), + eventChan: ch, } } @@ -97,10 +94,7 @@ func (c *aiGatewayRouteController) Reconcile(ctx context.Context, req reconcile. logger.Error(err, "Failed to reconcile extProc config map") return ctrl.Result{}, err } - if err := c.reconcileExtProcDeployment(ctx, &aiGatewayRoute, ownerRef); err != nil { - logger.Error(err, "Failed to reconcile extProc deployment") - return ctrl.Result{}, err - } + if err := c.reconcileExtProcExtensionPolicy(ctx, &aiGatewayRoute, ownerRef); err != nil { logger.Error(err, "Failed to reconcile extension policy") return ctrl.Result{}, err @@ -122,6 +116,7 @@ func (c *aiGatewayRouteController) reconcileExtProcExtensionPolicy(ctx context.C } else if client.IgnoreNotFound(err) != nil { return fmt.Errorf("failed to get extension policy: %w", err) } + pm := egv1a1.BufferedExtProcBodyProcessingMode port := gwapiv1.PortNumber(1063) objNs := gwapiv1.Namespace(aiGatewayRoute.Namespace) @@ -178,97 +173,6 @@ func (c *aiGatewayRouteController) ensuresExtProcConfigMapExists(ctx context.Con return nil } -// reconcileExtProcDeployment reconciles the external processor's Deployment and Service. -func (c *aiGatewayRouteController) reconcileExtProcDeployment(ctx context.Context, aiGatewayRoute *aigv1a1.AIGatewayRoute, ownerRef []metav1.OwnerReference) error { - name := extProcName(aiGatewayRoute) - labels := map[string]string{"app": name, managedByLabel: "envoy-ai-gateway"} - - deployment, err := c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Get(ctx, extProcName(aiGatewayRoute), metav1.GetOptions{}) - if err != nil { - if client.IgnoreNotFound(err) == nil { - deployment = &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: aiGatewayRoute.Namespace, - OwnerReferences: ownerRef, - Labels: labels, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{MatchLabels: labels}, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: labels}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: name, - Image: c.defaultExtProcImage, - ImagePullPolicy: corev1.PullIfNotPresent, - Ports: []corev1.ContainerPort{{Name: "grpc", ContainerPort: 1063}}, - Args: []string{ - "-configPath", "/etc/ai-gateway/extproc/" + expProcConfigFileName, - "-logLevel", "info", // TODO: this should be configurable via FilterConfig API. - }, - VolumeMounts: []corev1.VolumeMount{ - {Name: "config", MountPath: "/etc/ai-gateway/extproc"}, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: "config", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: extProcName(aiGatewayRoute)}, - }, - }, - }, - }, - }, - }, - }, - } - applyExtProcDeploymentConfigUpdate(&deployment.Spec, aiGatewayRoute.Spec.FilterConfig) - _, err = c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Create(ctx, deployment, metav1.CreateOptions{}) - if err != nil { - return fmt.Errorf("failed to create deployment: %w", err) - } - c.logger.Info("Created deployment", "name", name) - } else { - return fmt.Errorf("failed to get deployment: %w", err) - } - } else { - applyExtProcDeploymentConfigUpdate(&deployment.Spec, aiGatewayRoute.Spec.FilterConfig) - if _, err = c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Update(ctx, deployment, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("failed to update deployment: %w", err) - } - } - - // This is static, so we don't need to update it. - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: aiGatewayRoute.Namespace, - OwnerReferences: ownerRef, - Labels: labels, - }, - Spec: corev1.ServiceSpec{ - Selector: labels, - Ports: []corev1.ServicePort{ - { - Name: "grpc", - Protocol: corev1.ProtocolTCP, - Port: 1063, - AppProtocol: ptr.To("grpc"), - }, - }, - }, - } - if _, err = c.kube.CoreV1().Services(aiGatewayRoute.Namespace).Create(ctx, service, metav1.CreateOptions{}); client.IgnoreAlreadyExists(err) != nil { - return fmt.Errorf("failed to create Service %s.%s: %w", name, aiGatewayRoute.Namespace, err) - } - return nil -} - func extProcName(route *aigv1a1.AIGatewayRoute) string { return fmt.Sprintf("ai-eg-route-extproc-%s", route.Name) } diff --git a/internal/controller/ai_gateway_route_test.go b/internal/controller/ai_gateway_route_test.go index 5abe6c5c..ad4e461e 100644 --- a/internal/controller/ai_gateway_route_test.go +++ b/internal/controller/ai_gateway_route_test.go @@ -52,57 +52,6 @@ func TestAIGatewayRouteController_ensuresExtProcConfigMapExists(t *testing.T) { require.NoError(t, err) } -func TestAIGatewayRouteController_reconcileExtProcDeployment(t *testing.T) { - c := &aiGatewayRouteController{client: fake.NewClientBuilder().WithScheme(scheme).Build()} - c.kube = fake2.NewClientset() - - ownerRef := []metav1.OwnerReference{{APIVersion: "v1", Kind: "Kind", Name: "Name"}} - aiGatewayRoute := &aigv1a1.AIGatewayRoute{ - ObjectMeta: metav1.ObjectMeta{Name: "myroute", Namespace: "default"}, - Spec: aigv1a1.AIGatewayRouteSpec{ - FilterConfig: &aigv1a1.AIGatewayFilterConfig{ - Type: aigv1a1.AIGatewayFilterConfigTypeExternalProcess, - ExternalProcess: &aigv1a1.AIGatewayFilterConfigExternalProcess{ - Replicas: ptr.To[int32](123), - Resources: &corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("200m"), - corev1.ResourceMemory: resource.MustParse("100Mi"), - }, - }, - }, - }, - }, - } - - err := c.reconcileExtProcDeployment(context.Background(), aiGatewayRoute, ownerRef) - require.NoError(t, err) - - deployment, err := c.kube.AppsV1().Deployments("default").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, extProcName(aiGatewayRoute), deployment.Name) - require.Equal(t, int32(123), *deployment.Spec.Replicas) - require.Equal(t, ownerRef, deployment.OwnerReferences) - require.Equal(t, corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("200m"), - corev1.ResourceMemory: resource.MustParse("100Mi"), - }, - }, deployment.Spec.Template.Spec.Containers[0].Resources) - service, err := c.kube.CoreV1().Services("default").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, extProcName(aiGatewayRoute), service.Name) - - // Doing it again should not fail and update the deployment. - aiGatewayRoute.Spec.FilterConfig.ExternalProcess.Replicas = ptr.To[int32](456) - err = c.reconcileExtProcDeployment(context.Background(), aiGatewayRoute, ownerRef) - require.NoError(t, err) - // Check the deployment is updated. - deployment, err = c.kube.AppsV1().Deployments("default").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, int32(456), *deployment.Spec.Replicas) -} - func TestAIGatewayRouteController_reconcileExtProcExtensionPolicy(t *testing.T) { c := &aiGatewayRouteController{client: fake.NewClientBuilder().WithScheme(scheme).Build()} ownerRef := []metav1.OwnerReference{{APIVersion: "v1", Kind: "Kind", Name: "Name"}} diff --git a/internal/controller/ai_service_backend.go b/internal/controller/ai_service_backend.go index 148a6732..c9e0a0cf 100644 --- a/internal/controller/ai_service_backend.go +++ b/internal/controller/ai_service_backend.go @@ -2,6 +2,7 @@ package controller import ( "context" + "fmt" "github.com/go-logr/logr" "k8s.io/client-go/kubernetes" @@ -12,6 +13,10 @@ import ( aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" ) +const ( + k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend = "BackendSecurityPolicyToReferencingAIServiceBackend" +) + // aiBackendController implements [reconcile.TypedReconciler] for [aigv1a1.AIServiceBackend]. // // This handles the AIServiceBackend resource and sends it to the config sink so that it can modify the configuration together with the state of other resources. @@ -47,3 +52,12 @@ func (l *aiBackendController) Reconcile(ctx context.Context, req reconcile.Reque l.eventChan <- aiBackend.DeepCopy() return ctrl.Result{}, nil } + +func aiServiceBackendIndexFunc(o client.Object) []string { + aiServiceBackend := o.(*aigv1a1.AIServiceBackend) + var ret []string + if ref := aiServiceBackend.Spec.BackendSecurityPolicyRef; ref != nil { + ret = append(ret, fmt.Sprintf("%s.%s", ref.Name, aiServiceBackend.Namespace)) + } + return ret +} diff --git a/internal/controller/ai_service_backend_test.go b/internal/controller/ai_service_backend_test.go index a3c47b40..06cf088a 100644 --- a/internal/controller/ai_service_backend_test.go +++ b/internal/controller/ai_service_backend_test.go @@ -6,11 +6,15 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" fake2 "k8s.io/client-go/kubernetes/fake" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" ) @@ -30,3 +34,86 @@ func TestAIServiceBackendController_Reconcile(t *testing.T) { require.Equal(t, "mybackend", item.(*aigv1a1.AIServiceBackend).Name) require.Equal(t, "default", item.(*aigv1a1.AIServiceBackend).Namespace) } + +func Test_AiServiceBackendIndexFunc(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, aigv1a1.AddToScheme(scheme)) + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&aigv1a1.AIServiceBackend{}, k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend, aiServiceBackendIndexFunc). + Build() + + // Create Backend Security Policies. + for _, bsp := range []*aigv1a1.BackendSecurityPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Name: "some-backend-security-policy-1", Namespace: "ns"}, + Spec: aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy-1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "some-backend-security-policy-3", Namespace: "ns"}, + Spec: aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy-3", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + }, + } { + require.NoError(t, c.Create(context.Background(), bsp, &client.CreateOptions{})) + } + + // Create AI Service Backends. + for _, backend := range []*aigv1a1.AIServiceBackend{ + { + ObjectMeta: metav1.ObjectMeta{Name: "one", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "two", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend2", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "three", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend3", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-3"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "four", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend4", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + } { + require.NoError(t, c.Create(context.Background(), backend, &client.CreateOptions{})) + } + + var aiServiceBackend aigv1a1.AIServiceBackendList + require.NoError(t, c.List(context.Background(), &aiServiceBackend, + client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: "some-backend-security-policy-1.ns"})) + require.Len(t, aiServiceBackend.Items, 2) + require.Equal(t, "one", aiServiceBackend.Items[0].Name) + require.Equal(t, "two", aiServiceBackend.Items[1].Name) + + require.NoError(t, c.List(context.Background(), &aiServiceBackend, + client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: "some-backend-security-policy-2.ns"})) + require.Empty(t, aiServiceBackend.Items) + + require.NoError(t, c.List(context.Background(), &aiServiceBackend, + client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: "some-backend-security-policy-3.ns"})) + require.Len(t, aiServiceBackend.Items, 1) + require.Equal(t, "three", aiServiceBackend.Items[0].Name) +} diff --git a/internal/controller/backend_security_policy.go b/internal/controller/backend_security_policy.go new file mode 100644 index 00000000..917c68da --- /dev/null +++ b/internal/controller/backend_security_policy.go @@ -0,0 +1,49 @@ +package controller + +import ( + "context" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" +) + +// backendSecurityPolicyController implements [reconcile.TypedReconciler] for [aigv1a1.BackendSecurityPolicy]. +// +// This handles the BackendSecurityPolicy resource and sends it to the config sink so that it can modify configuration. +type backendSecurityPolicyController struct { + client client.Client + kube kubernetes.Interface + logger logr.Logger + eventChan chan ConfigSinkEvent +} + +func newBackendSecurityPolicyController(client client.Client, kube kubernetes.Interface, logger logr.Logger, ch chan ConfigSinkEvent) *backendSecurityPolicyController { + return &backendSecurityPolicyController{ + client: client, + kube: kube, + logger: logger, + eventChan: ch, + } +} + +// Reconcile implements the [reconcile.TypedReconciler] for [aigv1a1.BackendSecurityPolicy]. +func (b backendSecurityPolicyController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var backendSecurityPolicy aigv1a1.BackendSecurityPolicy + if err := b.client.Get(ctx, req.NamespacedName, &backendSecurityPolicy); err != nil { + if errors.IsNotFound(err) { + ctrl.Log.Info("Deleting Backend Security Policy", + "namespace", req.Namespace, "name", req.Name) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + // Send the backend security policy to the config sink so that it can modify the configuration together with the state of other resources. + b.eventChan <- backendSecurityPolicy.DeepCopy() + return ctrl.Result{}, nil +} diff --git a/internal/controller/backend_security_policy_test.go b/internal/controller/backend_security_policy_test.go new file mode 100644 index 00000000..c47f0f44 --- /dev/null +++ b/internal/controller/backend_security_policy_test.go @@ -0,0 +1,34 @@ +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + fake2 "k8s.io/client-go/kubernetes/fake" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" +) + +func TestBackendSecurityController_Reconcile(t *testing.T) { + ch := make(chan ConfigSinkEvent, 100) + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + c := newBackendSecurityPolicyController(cl, fake2.NewClientset(), ctrl.Log, ch) + backendSecurityPolicyName := "mybackendSecurityPolicy" + namespace := "default" + + err := cl.Create(context.Background(), &aigv1a1.BackendSecurityPolicy{ObjectMeta: metav1.ObjectMeta{Name: backendSecurityPolicyName, Namespace: namespace}}) + require.NoError(t, err) + _, err = c.Reconcile(context.Background(), reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: backendSecurityPolicyName}}) + require.NoError(t, err) + item, ok := <-ch + require.True(t, ok) + require.IsType(t, &aigv1a1.BackendSecurityPolicy{}, item) + require.Equal(t, backendSecurityPolicyName, item.(*aigv1a1.BackendSecurityPolicy).Name) + require.Equal(t, namespace, item.(*aigv1a1.BackendSecurityPolicy).Namespace) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index eda9a59b..0edc1505 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -67,7 +67,7 @@ func StartControllers(ctx context.Context, config *rest.Config, logger logr.Logg } sinkChan := make(chan ConfigSinkEvent, 100) - routeC := NewAIGatewayRouteController(c, kubernetes.NewForConfigOrDie(config), logger, options, sinkChan) + routeC := NewAIGatewayRouteController(c, kubernetes.NewForConfigOrDie(config), logger, sinkChan) if err = ctrl.NewControllerManagedBy(mgr). For(&aigv1a1.AIGatewayRoute{}). Complete(routeC); err != nil { @@ -81,7 +81,14 @@ func StartControllers(ctx context.Context, config *rest.Config, logger logr.Logg return fmt.Errorf("failed to create controller for AIServiceBackend: %w", err) } - sink := newConfigSink(c, kubernetes.NewForConfigOrDie(config), logger, sinkChan) + backendSecurityPolicyC := newBackendSecurityPolicyController(c, kubernetes.NewForConfigOrDie(config), logger, sinkChan) + if err = ctrl.NewControllerManagedBy(mgr). + For(&aigv1a1.BackendSecurityPolicy{}). + Complete(backendSecurityPolicyC); err != nil { + return fmt.Errorf("failed to create controller for BackendSecurityPolicy: %w", err) + } + + sink := newConfigSink(c, kubernetes.NewForConfigOrDie(config), logger, sinkChan, options.ExtProcImage) // Before starting the manager, initialize the config sink to sync all AIServiceBackend and AIGatewayRoute objects in the cluster. logger.Info("Initializing config sink") @@ -102,5 +109,11 @@ func applyIndexing(indexer client.FieldIndexer) error { if err != nil { return fmt.Errorf("failed to index field for AIGatewayRoute: %w", err) } + err = indexer.IndexField(context.Background(), &aigv1a1.AIServiceBackend{}, + k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend, aiServiceBackendIndexFunc) + if err != nil { + return fmt.Errorf("failed to index field for AIServiceBackend: %w", err) + } + return nil } diff --git a/internal/controller/sink.go b/internal/controller/sink.go index 41de9ded..70a857d5 100644 --- a/internal/controller/sink.go +++ b/internal/controller/sink.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/go-logr/logr" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/utils/ptr" @@ -19,6 +21,11 @@ import ( const selectedBackendHeaderKey = "x-ai-eg-selected-backend" +// mountedExtProcSecretPath specifies the secret file mounted on the external proc. The idea is to update the mounted +// +// secret with backendSecurityPolicy auth instead of mounting new secret files to the external proc. +const mountedExtProcSecretPath = "/etc/backend_security_policy" // #nosec G101 + // ConfigSinkEvent is the interface for the events that the configSink can handle. // It can be either an AIServiceBackend, an AIGatewayRoute, or a deletion event. // @@ -30,9 +37,11 @@ type ConfigSinkEvent any // consolidate the information from both objects to generate the ExtProcConfig // and HTTPRoute objects. type configSink struct { - client client.Client - kube kubernetes.Interface - logger logr.Logger + client client.Client + kube kubernetes.Interface + logger logr.Logger + defaultExtProcImage string + defaultExtProcImagePullPolicy corev1.PullPolicy eventChan chan ConfigSinkEvent } @@ -42,12 +51,15 @@ func newConfigSink( kube kubernetes.Interface, logger logr.Logger, eventChan chan ConfigSinkEvent, + extProcImage string, ) *configSink { c := &configSink{ - client: kubeClient, - kube: kube, - logger: logger.WithName("config-sink"), - eventChan: eventChan, + client: kubeClient, + kube: kube, + logger: logger.WithName("config-sink"), + defaultExtProcImage: extProcImage, + defaultExtProcImagePullPolicy: corev1.PullIfNotPresent, + eventChan: eventChan, } return c } @@ -60,8 +72,15 @@ func (c *configSink) backend(namespace, name string) (*aigv1a1.AIServiceBackend, return backend, nil } -// init caches all AIServiceBackend and AIGatewayRoute objects in the cluster after the controller gets the leader election, -// and starts a goroutine to handle the events from the controllers. +func (c *configSink) backendSecurityPolicy(namespace, name string) (*aigv1a1.BackendSecurityPolicy, error) { + backendSecurityPolicy := &aigv1a1.BackendSecurityPolicy{} + if err := c.client.Get(context.Background(), client.ObjectKey{Name: name, Namespace: namespace}, backendSecurityPolicy); err != nil { + return nil, err + } + return backendSecurityPolicy, nil +} + +// init starts a goroutine to handle the events from the controllers. func (c *configSink) init(ctx context.Context) error { go func() { for { @@ -84,6 +103,8 @@ func (c *configSink) handleEvent(event ConfigSinkEvent) { c.syncAIServiceBackend(e) case *aigv1a1.AIGatewayRoute: c.syncAIGatewayRoute(e) + case *aigv1a1.BackendSecurityPolicy: + c.syncBackendSecurityPolicy(e) default: panic(fmt.Sprintf("unexpected event type: %T", e)) } @@ -136,6 +157,13 @@ func (c *configSink) syncAIGatewayRoute(aiGatewayRoute *aigv1a1.AIGatewayRoute) c.logger.Error(err, "failed to update extproc configmap", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) return } + + // Deploy extproc deployment with potential updates. + err = c.syncExtProcDeployment(context.Background(), aiGatewayRoute) + if err != nil { + c.logger.Error(err, "failed to deploy ext proc", "namespace", aiGatewayRoute.Namespace, "name", aiGatewayRoute.Name) + return + } } func (c *configSink) syncAIServiceBackend(aiBackend *aigv1a1.AIServiceBackend) { @@ -155,6 +183,20 @@ func (c *configSink) syncAIServiceBackend(aiBackend *aigv1a1.AIServiceBackend) { } } +func (c *configSink) syncBackendSecurityPolicy(bsp *aigv1a1.BackendSecurityPolicy) { + key := fmt.Sprintf("%s.%s", bsp.Name, bsp.Namespace) + var aiServiceBackends aigv1a1.AIServiceBackendList + err := c.client.List(context.Background(), &aiServiceBackends, client.MatchingFields{k8sClientIndexBackendSecurityPolicyToReferencingAIServiceBackend: key}) + if err != nil { + c.logger.Error(err, "failed to list AIServiceBackendList", "backendSecurityPolicy", key) + return + } + for i := range aiServiceBackends.Items { + aiBackend := &aiServiceBackends.Items[i] + c.syncAIServiceBackend(aiBackend) + } +} + // updateExtProcConfigMap updates the external process configmap with the new AIGatewayRoute. func (c *configSink) updateExtProcConfigMap(aiGatewayRoute *aigv1a1.AIGatewayRoute) error { configMap, err := c.kube.CoreV1().ConfigMaps(aiGatewayRoute.Namespace).Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) @@ -184,6 +226,22 @@ func (c *configSink) updateExtProcConfigMap(aiGatewayRoute *aigv1a1.AIGatewayRou ec.Rules[i].Backends[j].Schema.Name = filterconfig.APISchemaName(backendObj.Spec.APISchema.Name) ec.Rules[i].Backends[j].Schema.Version = backendObj.Spec.APISchema.Version } + + if bspRef := backendObj.Spec.BackendSecurityPolicyRef; bspRef != nil { + bspKey := fmt.Sprintf("%s.%s", bspRef.Name, aiGatewayRoute.Namespace) + backendSecurityPolicy, err := c.backendSecurityPolicy(aiGatewayRoute.Namespace, string(bspRef.Name)) + if err != nil { + return fmt.Errorf("failed to get BackendSecurityPolicy %s: %w", bspRef.Name, err) + } + + if backendSecurityPolicy.Spec.Type == aigv1a1.BackendSecurityPolicyTypeAPIKey { + ec.Rules[i].Backends[j].Auth = &filterconfig.BackendAuth{ + APIKey: &filterconfig.APIKeyAuth{Filename: getBackendSecurityMountPath(bspKey)}, + } + } else { + return fmt.Errorf("invalid backend security type %s for policy %s", backendSecurityPolicy.Spec.Type, bspKey) + } + } } ec.Rules[i].Headers = make([]filterconfig.HeaderMatch, len(rule.Matches)) for j, match := range rule.Matches { @@ -289,3 +347,161 @@ func (c *configSink) newHTTPRoute(dst *gwapiv1.HTTPRoute, aiGatewayRoute *aigv1a dst.Spec.CommonRouteSpec.ParentRefs = parentRefs return nil } + +// syncExtProcDeployment syncs the external processor's Deployment and Service. +func (c *configSink) syncExtProcDeployment(ctx context.Context, aiGatewayRoute *aigv1a1.AIGatewayRoute) error { + name := extProcName(aiGatewayRoute) + labels := map[string]string{"app": name, managedByLabel: "envoy-ai-gateway"} + + deployment, err := c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Get(ctx, extProcName(aiGatewayRoute), metav1.GetOptions{}) + if err != nil { + if client.IgnoreNotFound(err) == nil { + deployment = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: aiGatewayRoute.Namespace, + OwnerReferences: ownerReferenceForAIGatewayRoute(aiGatewayRoute), + Labels: labels, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: name, + Image: c.defaultExtProcImage, + ImagePullPolicy: c.defaultExtProcImagePullPolicy, + Ports: []corev1.ContainerPort{{Name: "grpc", ContainerPort: 1063}}, + Args: []string{ + "-configPath", "/etc/ai-gateway/extproc/" + expProcConfigFileName, + "-logLevel", "info", + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: "config", MountPath: "/etc/ai-gateway/extproc"}, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: extProcName(aiGatewayRoute)}, + }, + }, + }, + }, + }, + }, + }, + } + updatedSpec, err := c.mountBackendSecurityPolicySecrets(&deployment.Spec.Template.Spec, aiGatewayRoute) + if err == nil { + deployment.Spec.Template.Spec = *updatedSpec + } + applyExtProcDeploymentConfigUpdate(&deployment.Spec, aiGatewayRoute.Spec.FilterConfig) + _, err = c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create deployment: %w", err) + } + c.logger.Info("Created deployment", "name", name) + } else { + return fmt.Errorf("failed to get deployment: %w", err) + } + } else { + updatedSpec, err := c.mountBackendSecurityPolicySecrets(&deployment.Spec.Template.Spec, aiGatewayRoute) + if err == nil { + deployment.Spec.Template.Spec = *updatedSpec + } + applyExtProcDeploymentConfigUpdate(&deployment.Spec, aiGatewayRoute.Spec.FilterConfig) + if _, err = c.kube.AppsV1().Deployments(aiGatewayRoute.Namespace).Update(ctx, deployment, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update deployment: %w", err) + } + } + + // This is static, so we don't need to update it. + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: aiGatewayRoute.Namespace, + OwnerReferences: ownerReferenceForAIGatewayRoute(aiGatewayRoute), + Labels: labels, + }, + Spec: corev1.ServiceSpec{ + Selector: labels, + Ports: []corev1.ServicePort{ + { + Name: "grpc", + Protocol: corev1.ProtocolTCP, + Port: 1063, + AppProtocol: ptr.To("grpc"), + }, + }, + }, + } + if _, err = c.kube.CoreV1().Services(aiGatewayRoute.Namespace).Create(ctx, service, metav1.CreateOptions{}); client.IgnoreAlreadyExists(err) != nil { + return fmt.Errorf("failed to create Service %s.%s: %w", name, aiGatewayRoute.Namespace, err) + } + return nil +} + +// mountBackendSecurityPolicySecrets will mount secrets based on backendSecurityPolicies attached to AIServiceBackend. +func (c *configSink) mountBackendSecurityPolicySecrets(spec *corev1.PodSpec, aiGatewayRoute *aigv1a1.AIGatewayRoute) (*corev1.PodSpec, error) { + // Mount from scratch to avoid secrets that should be unmounted. + // Only keep the original mount which should be the config volume. + spec.Volumes = spec.Volumes[:1] + container := &spec.Containers[0] + container.VolumeMounts = container.VolumeMounts[:1] + + mountedSecrets := make(map[string]bool) + + for _, rule := range aiGatewayRoute.Spec.Rules { + for _, backendRef := range rule.BackendRefs { + backend, err := c.backend(aiGatewayRoute.Namespace, backendRef.Name) + if err != nil { + return nil, fmt.Errorf("failed to get backend %s: %w", backendRef.Name, err) + } + + if backendSecurityPolicyRef := backend.Spec.BackendSecurityPolicyRef; backendSecurityPolicyRef != nil { + bspKey := fmt.Sprintf("%s.%s", backend.Spec.BackendSecurityPolicyRef.Name, aiGatewayRoute.Namespace) + backendSecurityPolicy, err := c.backendSecurityPolicy(aiGatewayRoute.Namespace, string(backendSecurityPolicyRef.Name)) + if err != nil { + return nil, fmt.Errorf("failed to get backend security policy %s: %w", backendSecurityPolicyRef.Name, err) + } + + var secretName string + if backendSecurityPolicy.Spec.Type == aigv1a1.BackendSecurityPolicyTypeAPIKey { + secretName = string(backendSecurityPolicy.Spec.APIKey.SecretRef.Name) + } else { + return nil, fmt.Errorf("backend security policy %s is not supported", backendSecurityPolicy.Spec.Type) + } + + if _, ok := mountedSecrets[secretName]; !ok { + spec.Volumes = append(spec.Volumes, corev1.Volume{ + Name: bspKey, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + }, + }, + }) + + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: bspKey, + MountPath: getBackendSecurityMountPath(bspKey), + }) + + mountedSecrets[secretName] = true + } + } + } + } + + return spec, nil +} + +func getBackendSecurityMountPath(backendSecurityPolicyKey string) string { + return fmt.Sprintf("%s/%s", mountedExtProcSecretPath, backendSecurityPolicyKey) +} diff --git a/internal/controller/sink_test.go b/internal/controller/sink_test.go index 5e461e04..b58e16dc 100644 --- a/internal/controller/sink_test.go +++ b/internal/controller/sink_test.go @@ -6,10 +6,12 @@ import ( "log/slog" "os" "testing" + "time" "github.com/go-logr/logr" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/yaml" fake2 "k8s.io/client-go/kubernetes/fake" @@ -17,6 +19,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" "github.com/envoyproxy/ai-gateway/filterconfig" @@ -27,7 +30,7 @@ func TestConfigSink_init(t *testing.T) { kube := fake2.NewClientset() eventChan := make(chan ConfigSinkEvent) - s := newConfigSink(fakeClient, kube, logr.Discard(), eventChan) + s := newConfigSink(fakeClient, kube, logr.Discard(), eventChan, "defaultExtProcImage") require.NotNil(t, s) } @@ -36,7 +39,7 @@ func TestConfigSink_syncAIGatewayRoute(t *testing.T) { kube := fake2.NewClientset() eventChan := make(chan ConfigSinkEvent, 10) - s := newConfigSink(fakeClient, kube, logr.FromSlogHandler(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{})), eventChan) + s := newConfigSink(fakeClient, kube, logr.FromSlogHandler(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{})), eventChan, "defaultExtProcImage") require.NotNil(t, s) for _, backend := range []*aigv1a1.AIServiceBackend{ @@ -100,14 +103,31 @@ func TestConfigSink_syncAIGatewayRoute(t *testing.T) { func TestConfigSink_syncAIServiceBackend(t *testing.T) { eventChan := make(chan ConfigSinkEvent) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - s := newConfigSink(fakeClient, nil, logr.Discard(), eventChan) + s := newConfigSink(fakeClient, nil, logr.Discard(), eventChan, "defaultExtProcImage") s.syncAIServiceBackend(&aigv1a1.AIServiceBackend{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns1"}}) } +func TestConfigSink_syncBackendSecurityPolicy(t *testing.T) { + eventChan := make(chan ConfigSinkEvent) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + backend := aigv1a1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{Name: "tomato", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "new-backend-security-policy"}, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), &backend, &client.CreateOptions{})) + + s := newConfigSink(fakeClient, nil, logr.Discard(), eventChan, "defaultExtProcImage") + s.syncBackendSecurityPolicy(&aigv1a1.BackendSecurityPolicy{ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}}) +} + func Test_newHTTPRoute(t *testing.T) { eventChan := make(chan ConfigSinkEvent) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - s := newConfigSink(fakeClient, nil, logr.Discard(), eventChan) + s := newConfigSink(fakeClient, nil, logr.Discard(), eventChan, "defaultExtProcImage") httpRoute := &gwapiv1.HTTPRoute{ ObjectMeta: metav1.ObjectMeta{Name: "route1", Namespace: "ns1"}, Spec: gwapiv1.HTTPRouteSpec{}, @@ -210,7 +230,25 @@ func Test_updateExtProcConfigMap(t *testing.T) { kube := fake2.NewClientset() eventChan := make(chan ConfigSinkEvent) - s := newConfigSink(fakeClient, kube, logr.Discard(), eventChan) + s := newConfigSink(fakeClient, kube, logr.Discard(), eventChan, "defaultExtProcImage") + err := fakeClient.Create(context.Background(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "some-secret-policy"}}) + require.NoError(t, err) + + for _, bsp := range []*aigv1a1.BackendSecurityPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Name: "some-backend-security-policy-1", Namespace: "ns"}, + Spec: aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + }, + } { + err := fakeClient.Create(context.Background(), bsp, &client.CreateOptions{}) + require.NoError(t, err) + } + for _, b := range []*aigv1a1.AIServiceBackend{ { ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}, @@ -218,13 +256,15 @@ func Test_updateExtProcConfigMap(t *testing.T) { APISchema: aigv1a1.VersionedAPISchema{ Name: aigv1a1.APISchemaAWSBedrock, }, - BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"}, }, }, { ObjectMeta: metav1.ObjectMeta{Name: "cat", Namespace: "ns"}, Spec: aigv1a1.AIServiceBackendSpec{ - BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend2", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend2", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"}, }, }, { @@ -296,13 +336,21 @@ func Test_updateExtProcConfigMap(t *testing.T) { Rules: []filterconfig.RouteRule{ { Backends: []filterconfig.Backend{ - {Name: "apple.ns", Weight: 1, Schema: filterconfig.VersionedAPISchema{Name: filterconfig.APISchemaAWSBedrock}}, {Name: "pineapple.ns", Weight: 2}, + {Name: "apple.ns", Weight: 1, Schema: filterconfig.VersionedAPISchema{Name: filterconfig.APISchemaAWSBedrock}, Auth: &filterconfig.BackendAuth{ + APIKey: &filterconfig.APIKeyAuth{ + Filename: "/etc/backend_security_policy/some-backend-security-policy-1.ns", + }, + }}, {Name: "pineapple.ns", Weight: 2}, }, Headers: []filterconfig.HeaderMatch{{Name: aigv1a1.AIModelHeaderKey, Value: "some-ai"}}, }, { - Backends: []filterconfig.Backend{{Name: "cat.ns", Weight: 1}}, - Headers: []filterconfig.HeaderMatch{{Name: aigv1a1.AIModelHeaderKey, Value: "another-ai"}}, + Backends: []filterconfig.Backend{{Name: "cat.ns", Weight: 1, Auth: &filterconfig.BackendAuth{ + APIKey: &filterconfig.APIKeyAuth{ + Filename: "/etc/backend_security_policy/some-backend-security-policy-1.ns", + }, + }}}, + Headers: []filterconfig.HeaderMatch{{Name: aigv1a1.AIModelHeaderKey, Value: "another-ai"}}, }, }, LLMRequestCosts: []filterconfig.LLMRequestCost{ @@ -334,3 +382,311 @@ func Test_updateExtProcConfigMap(t *testing.T) { }) } } + +func TestConfigSink_SyncExtprocDeployment(t *testing.T) { + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + kube := fake2.NewClientset() + + eventChan := make(chan ConfigSinkEvent) + s := newConfigSink(fakeClient, kube, logr.Discard(), eventChan, "envoyproxy/ai-gateway-extproc:foo") + err := fakeClient.Create(context.Background(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "some-secret-policy"}}) + require.NoError(t, err) + + for _, bsp := range []*aigv1a1.BackendSecurityPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Name: "some-backend-security-policy-1", Namespace: "ns"}, + Spec: aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + }, + } { + err := fakeClient.Create(context.Background(), bsp, &client.CreateOptions{}) + require.NoError(t, err) + } + + for _, b := range []*aigv1a1.AIServiceBackend{ + { + ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + APISchema: aigv1a1.VersionedAPISchema{ + Name: aigv1a1.APISchemaAWSBedrock, + }, + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "cat", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend2", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-backend-security-policy-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pineapple", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend3", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + } { + err := fakeClient.Create(context.Background(), b, &client.CreateOptions{}) + require.NoError(t, err) + } + require.NotNil(t, s) + + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "myroute", Namespace: "ns"}, + TypeMeta: metav1.TypeMeta{ + Kind: "AIGatewayRoute", // aiGatewayRoute controller typically adds these type meta + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + FilterConfig: &aigv1a1.AIGatewayFilterConfig{ + Type: aigv1a1.AIGatewayFilterConfigTypeExternalProcess, + ExternalProcess: &aigv1a1.AIGatewayFilterConfigExternalProcess{ + Replicas: ptr.To[int32](123), + Resources: &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("100Mi"), + }, + }, + }, + }, + APISchema: aigv1a1.VersionedAPISchema{Name: aigv1a1.APISchemaOpenAI, Version: "v123"}, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + {Name: "apple", Weight: 1}, + {Name: "pineapple", Weight: 2}, + }, + Matches: []aigv1a1.AIGatewayRouteRuleMatch{ + {Headers: []gwapiv1.HTTPHeaderMatch{{Name: aigv1a1.AIModelHeaderKey, Value: "some-ai"}}}, + }, + }, + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{{Name: "cat", Weight: 1}}, + Matches: []aigv1a1.AIGatewayRouteRuleMatch{ + {Headers: []gwapiv1.HTTPHeaderMatch{{Name: aigv1a1.AIModelHeaderKey, Value: "another-ai"}}}, + }, + }, + }, + TargetRefs: []gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + { + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Name: "gtw", Kind: "Gateway", Group: "gateway.networking.k8s.io", + }, + }, + }, + }, + } + + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute, &client.CreateOptions{})) + + t.Run("create", func(t *testing.T) { + err = s.syncExtProcDeployment(context.Background(), aiGatewayRoute) + require.NoError(t, err) + + resourceLimits := &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("100Mi"), + }, + } + require.Eventually(t, func() bool { + extProcDeployment, err := s.kube.AppsV1().Deployments("ns").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) + if err != nil { + t.Logf("failed to get deployment %s: %v", extProcName(aiGatewayRoute), err) + return false + } + require.Equal(t, "envoyproxy/ai-gateway-extproc:foo", extProcDeployment.Spec.Template.Spec.Containers[0].Image) + require.Len(t, extProcDeployment.OwnerReferences, 1) + require.Equal(t, "myroute", extProcDeployment.OwnerReferences[0].Name) + require.Equal(t, "AIGatewayRoute", extProcDeployment.OwnerReferences[0].Kind) + require.Equal(t, int32(123), *extProcDeployment.Spec.Replicas) + require.Equal(t, resourceLimits, &extProcDeployment.Spec.Template.Spec.Containers[0].Resources) + return true + }, 30*time.Second, 200*time.Millisecond) + + service, err := s.kube.CoreV1().Services("ns").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, extProcName(aiGatewayRoute), service.Name) + }) + + t.Run("update", func(t *testing.T) { + // Update fields in resource again + // Doing it again should not fail and update the deployment. + newResourceLimits := &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("300m"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + }, + } + aiGatewayRoute.Spec.FilterConfig.ExternalProcess.Resources = newResourceLimits + aiGatewayRoute.Spec.FilterConfig.ExternalProcess.Replicas = ptr.To[int32](456) + + require.NoError(t, s.syncExtProcDeployment(context.Background(), aiGatewayRoute)) + // Check the deployment is updated. + require.Eventually(t, func() bool { + extProcDeployment, err := s.kube.AppsV1().Deployments("ns").Get(context.Background(), extProcName(aiGatewayRoute), metav1.GetOptions{}) + if err != nil { + t.Logf("failed to get deployment %s: %v", extProcName(aiGatewayRoute), err) + return false + } + require.Equal(t, "envoyproxy/ai-gateway-extproc:foo", extProcDeployment.Spec.Template.Spec.Containers[0].Image) + require.Len(t, extProcDeployment.OwnerReferences, 1) + require.Equal(t, "myroute", extProcDeployment.OwnerReferences[0].Name) + require.Equal(t, "AIGatewayRoute", extProcDeployment.OwnerReferences[0].Kind) + require.Equal(t, int32(456), *extProcDeployment.Spec.Replicas) + require.Equal(t, newResourceLimits, &extProcDeployment.Spec.Template.Spec.Containers[0].Resources) + return true + }, 30*time.Second, 200*time.Millisecond) + }) +} + +func TestConfigSink_MountBackendSecurityPolicySecrets(t *testing.T) { + // Create simple case + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + kube := fake2.NewClientset() + + eventChan := make(chan ConfigSinkEvent) + s := newConfigSink(fakeClient, kube, logr.Discard(), eventChan, "defaultExtProcImage") + err := s.init(context.Background()) + require.NoError(t, err) + require.NoError(t, fakeClient.Create(context.Background(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "some-secret-policy"}})) + + for _, secret := range []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{Name: "some-secret-policy-1"}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "some-secret-policy-2"}, + }, + } { + require.NoError(t, fakeClient.Create(context.Background(), secret, &client.CreateOptions{})) + } + + for _, bsp := range []*aigv1a1.BackendSecurityPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Name: "some-other-backend-security-policy-1", Namespace: "ns"}, + Spec: aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy-1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "some-other-backend-security-policy-2", Namespace: "ns"}, + Spec: aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1a1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "some-secret-policy-2", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + }, + }, + }, + } { + require.NoError(t, fakeClient.Create(context.Background(), bsp, &client.CreateOptions{})) + } + + backend := aigv1a1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + APISchema: aigv1a1.VersionedAPISchema{ + Name: aigv1a1.APISchemaAWSBedrock, + }, + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-other-backend-security-policy-1"}, + }, + } + + require.NoError(t, fakeClient.Create(context.Background(), &backend, &client.CreateOptions{})) + require.NotNil(t, s) + + aiGateway := aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "myroute", Namespace: "ns"}, + Spec: aigv1a1.AIGatewayRouteSpec{ + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + {Name: "apple", Weight: 1}, + }, + Matches: []aigv1a1.AIGatewayRouteRuleMatch{ + {Headers: []gwapiv1.HTTPHeaderMatch{{Name: aigv1a1.AIModelHeaderKey, Value: "some-ai"}}}, + }, + }, + }, + }, + } + + spec := corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "some-cm-policy", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "some-cm-policy", + }, + }, + }, + }, + }, + Containers: []corev1.Container{ + { + VolumeMounts: []corev1.VolumeMount{ + { + Name: "some-cm-policy", + MountPath: "some-path", + }, + }, + }, + }, + } + + require.NoError(t, fakeClient.Create(context.Background(), &aiGateway, &client.CreateOptions{})) + + updatedSpec, err := s.mountBackendSecurityPolicySecrets(&spec, &aiGateway) + require.NoError(t, err) + + require.Len(t, updatedSpec.Volumes, 2) + require.Len(t, updatedSpec.Containers[0].VolumeMounts, 2) + require.Equal(t, "some-secret-policy-1", updatedSpec.Volumes[1].VolumeSource.Secret.SecretName) + require.Equal(t, "some-other-backend-security-policy-1.ns", updatedSpec.Volumes[1].Name) + require.Equal(t, "some-other-backend-security-policy-1.ns", updatedSpec.Containers[0].VolumeMounts[1].Name) + require.Equal(t, "/etc/backend_security_policy/some-other-backend-security-policy-1.ns", updatedSpec.Containers[0].VolumeMounts[1].MountPath) + + require.NoError(t, fakeClient.Delete(context.Background(), &backend, &client.DeleteOptions{})) + + // Update to new security policy. + backend = aigv1a1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{Name: "apple", Namespace: "ns"}, + Spec: aigv1a1.AIServiceBackendSpec{ + APISchema: aigv1a1.VersionedAPISchema{ + Name: aigv1a1.APISchemaAWSBedrock, + }, + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend1", Namespace: ptr.To[gwapiv1.Namespace]("ns")}, + BackendSecurityPolicyRef: &gwapiv1.LocalObjectReference{Name: "some-other-backend-security-policy-2"}, + }, + } + + require.NoError(t, fakeClient.Create(context.Background(), &backend, &client.CreateOptions{})) + require.NotNil(t, s) + + updatedSpec, err = s.mountBackendSecurityPolicySecrets(&spec, &aiGateway) + require.NoError(t, err) + + require.Len(t, updatedSpec.Volumes, 2) + require.Len(t, updatedSpec.Containers[0].VolumeMounts, 2) + require.Equal(t, "some-secret-policy-2", updatedSpec.Volumes[1].VolumeSource.Secret.SecretName) + require.Equal(t, "some-other-backend-security-policy-2.ns", updatedSpec.Volumes[1].Name) + require.Equal(t, "some-other-backend-security-policy-2.ns", updatedSpec.Containers[0].VolumeMounts[1].Name) + require.Equal(t, "/etc/backend_security_policy/some-other-backend-security-policy-2.ns", updatedSpec.Containers[0].VolumeMounts[1].MountPath) +} + +func Test_GetBackendSecurityMountPath(t *testing.T) { + mountPath := getBackendSecurityMountPath("policyName") + require.Equal(t, "/etc/backend_security_policy/policyName", mountPath) +} diff --git a/internal/extproc/backendauth/api_key.go b/internal/extproc/backendauth/api_key.go new file mode 100644 index 00000000..5c38bb60 --- /dev/null +++ b/internal/extproc/backendauth/api_key.go @@ -0,0 +1,40 @@ +package backendauth + +import ( + "fmt" + "os" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + + "github.com/envoyproxy/ai-gateway/filterconfig" +) + +// apiKeyHandler implements [Handler] for api key authz. +type apiKeyHandler struct { + fileName string +} + +func NewAPIKeyHandler(auth *filterconfig.APIKeyAuth) (Handler, error) { + return &apiKeyHandler{ + fileName: auth.Filename, + }, nil +} + +// Do implements [Handler.Do]. +// +// Extracts the api key from the local file and set it as an authorization header. +func (a *apiKeyHandler) Do(requestHeaders map[string]string, headerMut *extprocv3.HeaderMutation, _ *extprocv3.BodyMutation) error { + // TODO: Stop reading a file on request path. + secret, err := os.ReadFile(a.fileName) + if err != nil { + return err + } + + requestHeaders["Authorization"] = fmt.Sprintf("Bearer %s", string(secret)) + headerMut.SetHeaders = append(headerMut.SetHeaders, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{Key: "Authorization", RawValue: []byte(requestHeaders["Authorization"])}, + }) + + return nil +} diff --git a/internal/extproc/backendauth/api_key_test.go b/internal/extproc/backendauth/api_key_test.go new file mode 100644 index 00000000..dfc524cf --- /dev/null +++ b/internal/extproc/backendauth/api_key_test.go @@ -0,0 +1,67 @@ +package backendauth + +import ( + "os" + "testing" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/stretchr/testify/require" + + "github.com/envoyproxy/ai-gateway/filterconfig" +) + +func TestNewAPIKeyHandler(t *testing.T) { + auth := filterconfig.APIKeyAuth{Filename: "test"} + handler, err := NewAPIKeyHandler(&auth) + require.NoError(t, err) + require.NotNil(t, handler) +} + +func TestApiKeyHandler_Do(t *testing.T) { + apiKeyFile := t.TempDir() + "/test" + + f, err := os.Create(apiKeyFile) + require.NoError(t, err) + + _, err = f.WriteString("test") + require.NoError(t, err) + err = f.Sync() + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + + auth := filterconfig.APIKeyAuth{Filename: apiKeyFile} + handler, err := NewAPIKeyHandler(&auth) + require.NoError(t, err) + require.NotNil(t, handler) + + secret, err := os.ReadFile(auth.Filename) + require.NoError(t, err) + require.Equal(t, "test", string(secret)) + + requestHeaders := map[string]string{":method": "POST"} + headerMut := &extprocv3.HeaderMutation{ + SetHeaders: []*corev3.HeaderValueOption{ + {Header: &corev3.HeaderValue{ + Key: ":path", + Value: "/model/some-random-model/converse", + }}, + }, + } + bodyMut := &extprocv3.BodyMutation{ + Mutation: &extprocv3.BodyMutation_Body{ + Body: []byte(`{"messages": [{"role": "user", "content": [{"text": "Say this is a test!"}]}]}`), + }, + } + err = handler.Do(requestHeaders, headerMut, bodyMut) + require.NoError(t, err) + + bearerToken, ok := requestHeaders["Authorization"] + require.True(t, ok) + require.Equal(t, "Bearer test", bearerToken) + + require.Len(t, headerMut.SetHeaders, 2) + require.Equal(t, "Authorization", headerMut.SetHeaders[1].Header.Key) + require.Equal(t, []byte("Bearer test"), headerMut.SetHeaders[1].Header.GetRawValue()) +} diff --git a/internal/extproc/backendauth/auth.go b/internal/extproc/backendauth/auth.go index 311e5f94..f385a544 100644 --- a/internal/extproc/backendauth/auth.go +++ b/internal/extproc/backendauth/auth.go @@ -20,6 +20,8 @@ type Handler interface { func NewHandler(config *filterconfig.BackendAuth) (Handler, error) { if config.AWSAuth != nil { return newAWSHandler(config.AWSAuth) + } else if config.APIKey != nil { + return NewAPIKeyHandler(config.APIKey) } return nil, errors.New("no backend auth handler found") } diff --git a/tests/controller/controller_test.go b/tests/controller/controller_test.go index dd1493f4..3c70f9a4 100644 --- a/tests/controller/controller_test.go +++ b/tests/controller/controller_test.go @@ -219,12 +219,9 @@ func TestStartControllers(t *testing.T) { func TestAIGatewayRouteController(t *testing.T) { c, cfg, k := tests.NewEnvTest(t) - opts := controller.Options{ - ExtProcImage: "envoyproxy/ai-gateway-extproc:foo", - EnableLeaderElection: false, - } ch := make(chan controller.ConfigSinkEvent) - rc := controller.NewAIGatewayRouteController(c, k, logr.Discard(), opts, ch) + + rc := controller.NewAIGatewayRouteController(c, k, logr.Discard(), ch) opt := ctrl.Options{Scheme: c.Scheme(), LeaderElection: false, Controller: config.Controller{SkipNameValidation: ptr.To(true)}} mgr, err := ctrl.NewManager(cfg, opt) @@ -288,24 +285,11 @@ func TestAIGatewayRouteController(t *testing.T) { // Verify that they are the same. created := item.(*aigv1a1.AIGatewayRoute) + require.Equal(t, "myroute", created.Name) + require.Equal(t, "AIGatewayRoute", created.Kind) + created.TypeMeta = metav1.TypeMeta{} // This will be populated by the controller internally, so we ignore it. require.Equal(t, origin, created) - - // Deployment must be created. - require.Eventually(t, func() bool { - deployment, err := k.AppsV1().Deployments("default").Get(ctx, extProcName("myroute"), metav1.GetOptions{}) - if err != nil { - t.Logf("failed to get deployment %s: %v", extProcName("myroute"), err) - return false - } - require.Equal(t, "envoyproxy/ai-gateway-extproc:foo", deployment.Spec.Template.Spec.Containers[0].Image) - require.Len(t, deployment.OwnerReferences, 1) - require.Equal(t, "myroute", deployment.OwnerReferences[0].Name) - require.Equal(t, "AIGatewayRoute", deployment.OwnerReferences[0].Kind) - require.Equal(t, int32(5), *deployment.Spec.Replicas) - require.Equal(t, resourceReq, &deployment.Spec.Template.Spec.Containers[0].Resources) - return true - }, 30*time.Second, 200*time.Millisecond) }) t.Run("update", func(t *testing.T) { @@ -328,22 +312,6 @@ func TestAIGatewayRouteController(t *testing.T) { created := item.(*aigv1a1.AIGatewayRoute) created.TypeMeta = metav1.TypeMeta{} // This will be populated by the controller internally, so we ignore it. require.Equal(t, origin, created) - - // Deployment must be updated. - require.Eventually(t, func() bool { - deployment, err := k.AppsV1().Deployments("default").Get(ctx, extProcName("myroute"), metav1.GetOptions{}) - if err != nil { - t.Logf("failed to get deployment %s: %v", extProcName("myroute"), err) - return false - } - require.Equal(t, "envoyproxy/ai-gateway-extproc:foo", deployment.Spec.Template.Spec.Containers[0].Image) - require.Len(t, deployment.OwnerReferences, 1) - require.Equal(t, "myroute", deployment.OwnerReferences[0].Name) - require.Equal(t, "AIGatewayRoute", deployment.OwnerReferences[0].Kind) - require.Equal(t, int32(3), *deployment.Spec.Replicas) - require.Equal(t, newResource, &deployment.Spec.Template.Spec.Containers[0].Resources) - return true - }, 30*time.Second, 200*time.Millisecond) }) } diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 0bec3d2e..ad3c7fa7 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -149,7 +149,7 @@ func initAIGateway(ctx context.Context) (err error) { initLog(fmt.Sprintf("\tdone (took %.2fs in total)\n", elapsed.Seconds())) }() initLog("\tHelm Install") - helm := exec.CommandContext(ctx, "helm", "upgrade", "-i", "eaig", + helm := exec.CommandContext(ctx, "helm", "upgrade", "-i", "ai-eg", "../../manifests/charts/ai-gateway-helm", "-n", "envoy-ai-gateway-system", "--create-namespace") helm.Stdout = os.Stdout diff --git a/tests/extproc/envoy.yaml b/tests/extproc/envoy.yaml index 059d8833..84fb3e44 100644 --- a/tests/extproc/envoy.yaml +++ b/tests/extproc/envoy.yaml @@ -43,10 +43,6 @@ static_resources: route: host_rewrite_literal: api.openai.com cluster: openai - request_headers_to_add: - - header: - key: 'Authorization' - value: 'Bearer TEST_OPENAI_API_KEY' - match: prefix: "/" headers: diff --git a/tests/extproc/extproc_test.go b/tests/extproc/extproc_test.go index 2af714dd..02ef05bf 100644 --- a/tests/extproc/extproc_test.go +++ b/tests/extproc/extproc_test.go @@ -50,6 +50,16 @@ func TestE2E(t *testing.T) { openAIAPIKey := getEnvVarOrSkip(t, "TEST_OPENAI_API_KEY") requireRunEnvoy(t, accessLogPath, openAIAPIKey) configPath := t.TempDir() + "/extproc-config.yaml" + + // Test with APIKey. + apiKeyFilePath := t.TempDir() + "/open-ai-api-key" + file, err := os.Create(apiKeyFilePath) + require.NoError(t, err) + defer func() { require.NoError(t, file.Close()) }() + _, err = file.WriteString(openAIAPIKey) + require.NoError(t, err) + require.NoError(t, file.Sync()) + requireWriteExtProcConfig(t, configPath, &filterconfig.Config{ MetadataNamespace: "ai_gateway_llm_ns", LLMRequestCosts: []filterconfig.LLMRequestCost{ @@ -62,8 +72,10 @@ func TestE2E(t *testing.T) { ModelNameHeaderKey: "x-model-name", Rules: []filterconfig.RouteRule{ { - Backends: []filterconfig.Backend{{Name: "openai", Schema: openAISchema}}, - Headers: []filterconfig.HeaderMatch{{Name: "x-model-name", Value: "gpt-4o-mini"}}, + Backends: []filterconfig.Backend{{Name: "openai", Schema: openAISchema, Auth: &filterconfig.BackendAuth{ + APIKey: &filterconfig.APIKeyAuth{Filename: apiKeyFilePath}, + }}}, + Headers: []filterconfig.HeaderMatch{{Name: "x-model-name", Value: "gpt-4o-mini"}}, }, { Backends: []filterconfig.Backend{ @@ -73,6 +85,7 @@ func TestE2E(t *testing.T) { }, }, }) + requireExtProcWithAWSCredentials(t, configPath) t.Run("health-checking", func(t *testing.T) { @@ -238,8 +251,7 @@ func requireTestUpstream(t *testing.T) { // requireRunEnvoy starts the Envoy proxy with the provided configuration. func requireRunEnvoy(t *testing.T, accessLogPath string, openAIAPIKey string) { tmpDir := t.TempDir() - envoyYaml := strings.Replace(envoyYamlBase, "TEST_OPENAI_API_KEY", openAIAPIKey, 1) - envoyYaml = strings.Replace(envoyYaml, "ACCESS_LOG_PATH", accessLogPath, 1) + envoyYaml := strings.Replace(envoyYamlBase, "ACCESS_LOG_PATH", accessLogPath, 1) // Write the envoy.yaml file. envoyYamlPath := tmpDir + "/envoy.yaml"