Skip to content

Commit

Permalink
feat: Implementation of API Key Auth (#125)
Browse files Browse the repository at this point in the history
This PR implements APIKey Auth for backendSecurityPolicy. The api key
stored in a secret will be mounted to the extproc's pod, and later
extracted by the appropriate AuthHandler.

Moved extprocDeployment into the sink as we need to tinker with the
deployment if backend/backendSecurityPolicy is updated.

Removed the envoy config yaml's field add_request_header and replaced it
with new API Key Auth implementation. CI passes which means that API Key
mounted to extproc + specified on backend auth works.

Will work on adding AWS Credential file after this.

---------

Signed-off-by: Aaron Choo <[email protected]>
Signed-off-by: Takeshi Yoneda <[email protected]>
Co-authored-by: Takeshi Yoneda <[email protected]>
  • Loading branch information
aabchoo and mathetake authored Jan 21, 2025
1 parent d851a65 commit 7dc91db
Show file tree
Hide file tree
Showing 17 changed files with 940 additions and 226 deletions.
9 changes: 8 additions & 1 deletion filterconfig/filterconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
118 changes: 11 additions & 107 deletions internal/controller/ai_gateway_route.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
51 changes: 0 additions & 51 deletions internal/controller/ai_gateway_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
14 changes: 14 additions & 0 deletions internal/controller/ai_service_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package controller

import (
"context"
"fmt"

"github.com/go-logr/logr"
"k8s.io/client-go/kubernetes"
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
87 changes: 87 additions & 0 deletions internal/controller/ai_service_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
}
Loading

0 comments on commit 7dc91db

Please sign in to comment.