pkg/controller/keyvault/gateway_secret_provider_class.go (192 lines of code) (raw):
package keyvault
import (
"context"
"errors"
"fmt"
"github.com/Azure/aks-app-routing-operator/pkg/config"
"github.com/Azure/aks-app-routing-operator/pkg/controller/controllername"
"github.com/Azure/aks-app-routing-operator/pkg/controller/metrics"
"github.com/Azure/aks-app-routing-operator/pkg/manifests"
"github.com/Azure/aks-app-routing-operator/pkg/util"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
secv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
var gatewaySecretProviderControllerName = controllername.New("gateway", "keyvault", "secret", "provider")
// GatewaySecretProviderClassReconciler manages a SecretProviderClass for Gateway resource that specifies a ServiceAccount
// and Keyvault URI in its TLS options field. The SPC is used to mirror the Keyvault values into
// a k8s secret so that it can be used by the CRD controller.
type GatewaySecretProviderClassReconciler struct {
client client.Client
events record.EventRecorder
config *config.Config
}
func NewGatewaySecretClassProviderReconciler(manager ctrl.Manager, conf *config.Config, serviceAccountIndexName string) error {
metrics.InitControllerMetrics(gatewaySecretProviderControllerName)
return gatewaySecretProviderControllerName.AddToController(
ctrl.
NewControllerManagedBy(manager).
For(&gatewayv1.Gateway{}).
Owns(&secv1.SecretProviderClass{}).
Watches(&corev1.ServiceAccount{}, handler.EnqueueRequestsFromMapFunc(generateGatewayGetter(manager, serviceAccountIndexName))), manager.GetLogger(),
).Complete(&GatewaySecretProviderClassReconciler{
client: manager.GetClient(),
events: manager.GetEventRecorderFor("aks-app-routing-operator"),
config: conf,
})
}
func (g *GatewaySecretProviderClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, retErr error) {
// set up metrics given result/error
defer func() {
metrics.HandleControllerReconcileMetrics(gatewaySecretProviderControllerName, res, retErr)
}()
// set up logger
logger, err := logr.FromContext(ctx)
if err != nil {
return ctrl.Result{}, fmt.Errorf("creating logger: %w", err)
}
logger = gatewaySecretProviderControllerName.AddToLogger(logger).WithValues("name", req.Name, "namespace", req.Namespace)
// retrieve gateway resource from request + log the get attempt, but ignore not found
gwObj := &gatewayv1.Gateway{}
err = g.client.Get(ctx, req.NamespacedName, gwObj)
if err != nil {
if client.IgnoreNotFound(err) != nil {
logger.Error(err, "failed to fetch Gateway")
return ctrl.Result{}, fmt.Errorf("fetching gateway: %w", err)
}
return ctrl.Result{}, nil
}
if !shouldReconcileGateway(gwObj) {
return ctrl.Result{}, nil
}
// check its TLS options - needs to have both cert uri and either serviceaccount name or clientid
for index, listener := range gwObj.Spec.Listeners {
spc := &secv1.SecretProviderClass{
TypeMeta: metav1.TypeMeta{
APIVersion: "secrets-store.csi.x-k8s.io/v1",
Kind: "SecretProviderClass",
},
ObjectMeta: metav1.ObjectMeta{
Name: generateGwListenerCertName(gwObj.Name, listener.Name),
Namespace: req.Namespace,
Labels: manifests.GetTopLevelLabels(),
OwnerReferences: []metav1.OwnerReference{{
APIVersion: gwObj.APIVersion,
Controller: util.ToPtr(true),
Kind: gwObj.Kind,
Name: gwObj.Name,
UID: gwObj.UID,
}},
},
}
logger = logger.WithValues("spc", spc.Name)
if listenerIsKvEnabled(listener) {
var clientId string
clientId, err = retrieveClientIdForListener(ctx, g.client, req.Namespace, listener.TLS.Options)
if err != nil {
var userErr util.UserError
if errors.As(err, &userErr) {
logger.Info(fmt.Sprintf("failed to fetch clientId for SPC for listener %s due to user error: %s, sending warning event", listener.Name, userErr.UserMessage))
g.events.Eventf(gwObj, corev1.EventTypeWarning, "InvalidInput", "invalid TLS configuration: %s", userErr.UserMessage)
return ctrl.Result{}, nil
}
logger.Error(err, fmt.Sprintf("failed to fetch clientId for listener %s: %s", listener.Name, err.Error()))
return ctrl.Result{}, fmt.Errorf("fetching clientId for listener: %w", err)
}
// otherwise it's active + valid - build SPC
certUri := string(listener.TLS.Options[certUriTLSOption])
logger.Info("building spc for listener and upserting")
spcConf := spcConfig{
ClientId: clientId,
TenantId: g.config.TenantID,
KeyvaultCertUri: certUri,
Name: generateGwListenerCertName(gwObj.Name, listener.Name),
}
err = buildSPC(spc, spcConf)
if err != nil {
var userErr util.UserError
if errors.As(err, &userErr) {
logger.Info("failed to build SecretProviderClass from user error: %s, sending warning event", userErr.UserMessage)
g.events.Eventf(gwObj, corev1.EventTypeWarning, "InvalidInput", "invalid TLS configuration: %s", userErr.UserMessage)
return ctrl.Result{}, nil
}
logger.Error(err, fmt.Sprintf("building SPC for listener %s: %s", listener.Name, err.Error()))
return ctrl.Result{}, fmt.Errorf("building spc: %w", err)
}
logger.Info(fmt.Sprintf("reconciling SecretProviderClass %s for listener %s", spc.Name, listener.Name))
if err = util.Upsert(ctx, g.client, spc); err != nil {
fullErr := fmt.Errorf("failed to reconcile SecretProviderClass %s: %w", req.Name, err)
logger.Error(err, fullErr.Error())
g.events.Event(gwObj, corev1.EventTypeWarning, "FailedUpdateOrCreateSPC", fullErr.Error())
return ctrl.Result{}, fullErr
}
logger.Info(fmt.Sprintf("preemptively attaching secret reference for listener %s", listener.Name))
newCertRef := gatewayv1.SecretObjectReference{
Namespace: to.Ptr(gatewayv1.Namespace(req.Namespace)),
Group: to.Ptr(gatewayv1.Group(corev1.GroupName)),
Kind: to.Ptr(gatewayv1.Kind("Secret")),
Name: gatewayv1.ObjectName(generateGwListenerCertName(gwObj.Name, listener.Name)),
}
gwObj.Spec.Listeners[index].TLS.CertificateRefs = []gatewayv1.SecretObjectReference{newCertRef}
continue
}
// we should delete the SPC if it exists
logger.Info(fmt.Sprintf("attempting to remove unused SPC %s", spc.Name))
deletionSpc := &secv1.SecretProviderClass{}
if err = g.client.Get(ctx, client.ObjectKeyFromObject(spc), deletionSpc); err != nil {
if client.IgnoreNotFound(err) != nil {
logger.Error(err, fmt.Sprintf("failed to fetch SPC for deletion %s", spc.Name))
return ctrl.Result{}, fmt.Errorf("fetching SPC for deletion: %w", err)
}
continue
}
if manifests.HasTopLevelLabels(deletionSpc.Labels) {
// return if we fail to delete, but otherwise, keep going
if err = g.client.Delete(ctx, deletionSpc); err != nil {
if client.IgnoreNotFound(err) != nil {
logger.Error(err, fmt.Sprintf("failed to delete SPC %s", spc.Name))
return ctrl.Result{}, fmt.Errorf("deleting SPC: %w", err)
}
continue
}
}
}
logger.Info("reconciling Gateway resource with new secret refs for each TLS-enabled listener")
if err = g.client.Update(ctx, gwObj); client.IgnoreNotFound(err) != nil {
if apierrors.IsConflict(err) {
logger.Info("Gateway resource was updated by another process, retrying")
return ctrl.Result{Requeue: true}, nil
}
fullErr := fmt.Errorf("failed to reconcile Gateway resource %s: %w", req.Name, err)
logger.Error(err, fullErr.Error())
g.events.Event(gwObj, corev1.EventTypeWarning, "FailedUpdateOrCreateGateway", fullErr.Error())
return ctrl.Result{}, fullErr
}
return ctrl.Result{}, nil
}
func generateGwListenerCertName(gw string, listener gatewayv1.SectionName) string {
certName := fmt.Sprintf("kv-gw-cert-%s-%s", gw, string(listener))
if len(certName) > 253 {
certName = certName[:253]
}
return certName
}
func listenerIsKvEnabled(listener gatewayv1.Listener) bool {
return listener.TLS != nil && listener.TLS.Options != nil && listener.TLS.Options[tlsCertKvUriAnnotation] != ""
}
func retrieveClientIdForListener(ctx context.Context, k8sclient client.Client, namespace string, options map[gatewayv1.AnnotationKey]gatewayv1.AnnotationValue) (string, error) {
certUri := string(options[certUriTLSOption])
saName := string(options[serviceAccountTLSOption])
// validate user input
if certUri != "" && saName == "" {
return "", util.NewUserError(errors.New("user specified cert URI but no ServiceAccount in a listener"), "KeyVault Cert URI provided, but the required ServiceAccount option was not. Please provide a ServiceAccount via the TLS option kubernetes.azure.com/tls-cert-service-account")
}
if certUri == "" && saName != "" {
return "", util.NewUserError(errors.New("user specified ServiceAccount but no cert URI in a listener"), "ServiceAccount for WorkloadIdentity provided, but KeyVault Cert URI was not. Please provide a TLS Cert URI via the TLS option kubernetes.azure.com/tls-cert-keyvault-uri")
}
// this should never happen since we check for this prior to this function call but just to be safe
if certUri == "" && saName == "" {
return "", util.NewUserError(errors.New("none of the required TLS options were specified"), "KeyVault Cert URI and ServiceAccount must both be specified to use TLS functionality in App Routing")
}
// pull service account
wiSaClientId, err := util.GetServiceAccountAndVerifyWorkloadIdentity(ctx, k8sclient, saName, namespace)
if err != nil {
return "", err
}
return wiSaClientId, nil
}