internal/controller/generic_controller.go (362 lines of code) (raw):
package controller
import (
"context"
"fmt"
"slices"
"strings"
"time"
msiacrpullv1beta1 "github.com/Azure/msi-acrpull/api/v1beta1"
msiacrpullv1beta2 "github.com/Azure/msi-acrpull/api/v1beta2"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
k8stypes "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
ctrl "sigs.k8s.io/controller-runtime"
crclient "sigs.k8s.io/controller-runtime/pkg/client"
)
// genericReconciler reconciles AcrPullBindings
type genericReconciler[O pullBinding] struct {
Client crclient.Client
Logger logr.Logger
Scheme *runtime.Scheme
NewBinding func() O
AddFinalizer func(O, string) O
RemoveFinalizer func(O, string) O
GetServiceAccountName func(O) string
GetPullSecretName func(O) string
GetInputsHash func(O) string
CreatePullCredential func(context.Context, O, *corev1.ServiceAccount) (string, time.Time, error)
UpdateStatusError func(O, string) O
NeedsRefresh func(logr.Logger, *corev1.Secret, func() time.Time) bool
RequeueAfter func(now func() time.Time) func(O) time.Duration
NeedsStatusUpdate func(time.Time, time.Time, O) bool
UpdateStatus func(time.Time, time.Time, O) O
now func() time.Time
}
func (r *genericReconciler[O]) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := r.Logger.WithValues("acrpullbinding", req.NamespacedName)
acrBinding := r.NewBinding()
if err := r.Client.Get(ctx, req.NamespacedName, acrBinding); err != nil {
if !apierrors.IsNotFound(err) {
msg := "unable to fetch acrPullBinding."
logger.Error(err, msg)
return ctrl.Result{}, fmt.Errorf("%s: %w", msg, err)
}
return ctrl.Result{}, nil
}
serviceAccount := &corev1.ServiceAccount{}
if err := r.Client.Get(ctx, k8stypes.NamespacedName{
Namespace: req.Namespace,
Name: r.GetServiceAccountName(acrBinding),
}, serviceAccount); err != nil {
if !apierrors.IsNotFound(err) {
msg := "failed to get service account"
logger.Error(err, msg)
return ctrl.Result{}, fmt.Errorf("%s: %w", msg, err)
} else {
serviceAccount = nil
}
}
var pullSecrets corev1.SecretList
if err := r.Client.List(ctx, &pullSecrets, crclient.InNamespace(acrBinding.GetNamespace()), crclient.MatchingFields{pullBindingField: acrBinding.GetName()}); err != nil {
msg := "failed to fetch pull secrets referencing pull binding"
logger.Error(err, msg)
return ctrl.Result{}, fmt.Errorf("%s: %w", msg, err)
}
var pullSecretNames []string
if len(pullSecrets.Items) == 0 {
pullSecretNames = append(pullSecretNames, r.GetPullSecretName(acrBinding))
} else {
for _, pullSecret := range pullSecrets.Items {
pullSecretNames = append(pullSecretNames, pullSecret.ObjectMeta.Name)
}
}
var referencingServiceAccounts []corev1.ServiceAccount
for _, pullSecret := range pullSecretNames {
var serviceAccountList corev1.ServiceAccountList
if err := r.Client.List(ctx, &serviceAccountList, crclient.InNamespace(acrBinding.GetNamespace()), crclient.MatchingFields{imagePullSecretsField: pullSecret}); err != nil {
msg := "failed to fetch service accounts referencing pull secret"
logger.Error(err, msg)
return ctrl.Result{}, fmt.Errorf("%s: %w", msg, err)
}
referencingServiceAccounts = append(referencingServiceAccounts, serviceAccountList.Items...)
}
action := r.reconcile(ctx, logger, acrBinding, serviceAccount, pullSecrets.Items, referencingServiceAccounts)
return action.execute(ctx, logger, r.Client, r.RequeueAfter(r.now))
}
func (r *genericReconciler[O]) reconcile(ctx context.Context, logger logr.Logger, acrBinding O, serviceAccount *corev1.ServiceAccount, pullSecrets []corev1.Secret, referencingServiceAccounts []corev1.ServiceAccount) *action[O] {
// examine DeletionTimestamp to determine if acr pull binding is under deletion
if acrBinding.GetDeletionTimestamp().IsZero() {
// the object is not being deleted, so if it does not have our finalizer,
// then need to add the finalizer and update the object.
if !slices.Contains(acrBinding.GetFinalizers(), msiAcrPullFinalizerName) {
logger.Info("adding finalizer to pull binding")
return &action[O]{updatePullBinding: r.AddFinalizer(acrBinding, msiAcrPullFinalizerName)}
}
} else {
// the object is being deleted, do cleanup as necessary
return r.cleanUp(acrBinding, serviceAccount, pullSecrets, logger)
}
// if the user changed which service account should be bound to this credential, we need to
// un-bind the credential from any service accounts it was bound to previously; if we're in
// the middle of cleaning up old pull secrets from this binding, we need to make sure all of
// them are removed from the previous service account
pullSecretNames := sets.Set[string]{}
for _, pullSecret := range pullSecrets {
pullSecretNames.Insert(pullSecret.ObjectMeta.Name)
}
extraneousServiceAccounts := slices.DeleteFunc(referencingServiceAccounts, func(other corev1.ServiceAccount) bool {
return serviceAccount != nil && other.Name == serviceAccount.Name
})
for _, extraneous := range extraneousServiceAccounts {
updated := extraneous.DeepCopy()
updated.ImagePullSecrets = slices.DeleteFunc(updated.ImagePullSecrets, func(reference corev1.LocalObjectReference) bool {
return reference.Name == r.GetPullSecretName(acrBinding) || pullSecretNames.Has(reference.Name)
})
if len(updated.ImagePullSecrets) != len(extraneous.ImagePullSecrets) {
logger.WithValues("serviceAccount", crclient.ObjectKeyFromObject(&extraneous).String()).Info("updating service account to remove image pull secret")
return &action[O]{updateServiceAccount: updated}
}
}
if serviceAccount == nil {
err := fmt.Sprintf("service account %q not found", r.GetServiceAccountName(acrBinding))
logger.Info(err)
return &action[O]{updatePullBindingStatus: r.UpdateStatusError(acrBinding, err)}
}
expectedPullSecretName := r.GetPullSecretName(acrBinding)
var pullSecret *corev1.Secret
for _, secret := range pullSecrets {
if secret.Name == expectedPullSecretName {
pullSecret = &secret
}
}
inputHash := r.GetInputsHash(acrBinding)
pullSecretMissing := pullSecret == nil
pullSecretNeedsRefresh := !pullSecretMissing && r.NeedsRefresh(r.Logger, pullSecret, r.now)
pullSecretInputsChanged := !pullSecretMissing && pullSecret.Annotations[tokenInputsAnnotation] != inputHash
if pullSecretMissing || pullSecretNeedsRefresh || pullSecretInputsChanged {
logger.WithValues("pullSecretMissing", pullSecretMissing, "pullSecretNeedsRefresh", pullSecretNeedsRefresh, "pullSecretInputsChanged", pullSecretInputsChanged).Info("generating new pull credential")
dockerConfig, expiresOn, err := r.CreatePullCredential(ctx, acrBinding, serviceAccount)
if err != nil {
logger.Info(err.Error())
return &action[O]{updatePullBindingStatus: r.UpdateStatusError(acrBinding, err.Error())}
}
newSecret := newPullSecret(acrBinding, r.GetPullSecretName(acrBinding), dockerConfig, r.Scheme, expiresOn, r.now, inputHash)
logger = logger.WithValues("secret", crclient.ObjectKeyFromObject(newSecret).String())
if pullSecret == nil {
logger.Info("creating pull credential secret")
return &action[O]{createSecret: newSecret}
} else {
logger.Info("updating pull credential secret")
return &action[O]{updateSecret: newSecret}
}
}
if !slices.ContainsFunc(serviceAccount.ImagePullSecrets, func(reference corev1.LocalObjectReference) bool {
return reference.Name == pullSecret.Name
}) {
updated := serviceAccount.DeepCopy()
updated.ImagePullSecrets = append(updated.ImagePullSecrets, corev1.LocalObjectReference{
Name: pullSecret.Name,
})
sortPullSecrets(updated)
logger.WithValues("serviceAccount", crclient.ObjectKeyFromObject(serviceAccount).String()).Info("updating service account to add image pull secret")
return &action[O]{updateServiceAccount: updated}
}
// clean up references to any extraneous pull secrets that refer to this binding
extraneous := sets.Set[string]{}
for _, secret := range pullSecrets {
if secret.ObjectMeta.Name != expectedPullSecretName {
extraneous.Insert(secret.ObjectMeta.Name)
}
}
if slices.ContainsFunc(serviceAccount.ImagePullSecrets, func(reference corev1.LocalObjectReference) bool {
return extraneous.Has(reference.Name)
}) {
updated := serviceAccount.DeepCopy()
updated.ImagePullSecrets = slices.DeleteFunc(updated.ImagePullSecrets, func(reference corev1.LocalObjectReference) bool {
return extraneous.Has(reference.Name)
})
sortPullSecrets(updated)
logger.WithValues("serviceAccount", crclient.ObjectKeyFromObject(serviceAccount).String()).Info("updating service account to remove extraneous image pull secrets")
return &action[O]{updateServiceAccount: updated}
}
// clean up any extraneous pull secrets that refer to this binding
for _, secret := range pullSecrets {
if secret.ObjectMeta.Name != expectedPullSecretName {
deleted := secret.DeepCopy()
logger.WithValues("secret", crclient.ObjectKeyFromObject(deleted).String()).Info("cleaning up extraneous pull credential")
return &action[O]{deleteSecret: deleted}
}
}
return r.setSuccessStatus(logger, acrBinding, pullSecret)
}
// sortPullSecrets ensures the semantically-correct ordering of pull secrets for the service account. The order of pull
// secrets determines the order in which the kubelet will use these credentials, so managing the order ensures we manage
// the order of preference for credentials. We enforce the following order:
// - pull secrets not managed by this controller
// - v1beta2 secrets (acr-pull-*)
// - v1beta1 secrets (*-msi-acrpull-secret)
func sortPullSecrets(serviceAccount *corev1.ServiceAccount) {
slices.SortFunc(serviceAccount.ImagePullSecrets, func(a, b corev1.LocalObjectReference) int {
var aType, bType pullSecretType
for value, into := range map[string]*pullSecretType{
a.Name: &aType,
b.Name: &bType,
} {
if isLegacySecretName(value) {
*into = pullSecretTypeLegacy
}
if isSecretName(value) {
*into = pullSecretTypeCurrent
}
}
switch aType {
case pullSecretTypeUnrelated:
switch bType {
case pullSecretTypeUnrelated:
return strings.Compare(a.Name, b.Name)
case pullSecretTypeLegacy:
return -1
case pullSecretTypeCurrent:
return -1
}
case pullSecretTypeLegacy:
switch bType {
case pullSecretTypeUnrelated:
return 1
case pullSecretTypeLegacy:
return strings.Compare(a.Name, b.Name)
case pullSecretTypeCurrent:
return 1
}
case pullSecretTypeCurrent:
switch bType {
case pullSecretTypeUnrelated:
return 1
case pullSecretTypeLegacy:
return -1
case pullSecretTypeCurrent:
return strings.Compare(a.Name, b.Name)
}
}
return strings.Compare(a.Name, b.Name)
})
}
type pullSecretType int
const (
pullSecretTypeUnrelated pullSecretType = iota
pullSecretTypeLegacy
pullSecretTypeCurrent
)
func (r *genericReconciler[O]) cleanUp(acrBinding O,
serviceAccount *corev1.ServiceAccount, pullSecrets []corev1.Secret, log logr.Logger) *action[O] {
if slices.Contains(acrBinding.GetFinalizers(), msiAcrPullFinalizerName) {
// our finalizer is present, so need to clean up ImagePullSecret reference
if serviceAccount == nil {
log.Info("service account not found, continuing to remove finalizer")
} else {
updated := serviceAccount.DeepCopy()
updated.ImagePullSecrets = slices.DeleteFunc(updated.ImagePullSecrets, func(reference corev1.LocalObjectReference) bool {
return reference.Name == r.GetPullSecretName(acrBinding)
})
if len(updated.ImagePullSecrets) != len(serviceAccount.ImagePullSecrets) {
log.WithValues("serviceAccount", crclient.ObjectKeyFromObject(serviceAccount).String()).Info("updating service account to remove image pull secret")
return &action[O]{updateServiceAccount: updated}
}
}
// remove the secrets
for _, pullSecret := range pullSecrets {
deleted := pullSecret.DeepCopy()
log.WithValues("secret", crclient.ObjectKeyFromObject(deleted).String()).Info("cleaning up pull credential")
return &action[O]{deleteSecret: deleted}
}
// remove our finalizer from the list and update it.
log.Info("removing finalizer from pull binding")
return &action[O]{updatePullBinding: r.RemoveFinalizer(acrBinding, msiAcrPullFinalizerName)}
}
log.Info("no finalizer present, nothing to do")
return nil
}
func (r *genericReconciler[O]) setSuccessStatus(log logr.Logger, acrBinding O, pullSecret *corev1.Secret) *action[O] {
log = log.WithValues("secret", crclient.ObjectKeyFromObject(pullSecret).String())
// malformed expiry and refresh annotations indicate some other actor corrupted our pull credential secret;
// we will re-generate it with correct values in the future, at which point we can update the pull binding
formattedExpiry, annotated := pullSecret.Annotations[tokenExpiryAnnotation]
if !annotated {
log.Info("token expiry annotation not present in secret")
return nil
}
expiry, err := time.Parse(time.RFC3339, formattedExpiry)
if err != nil {
log.Error(err, "failed to parse expiry annotation")
return nil
}
formattedRefresh, annotated := pullSecret.Annotations[tokenRefreshAnnotation]
if !annotated {
log.Info("token refresh annotation not present in secret")
return nil
}
refresh, err := time.Parse(time.RFC3339, formattedRefresh)
if err != nil {
log.Error(err, "failed to parse refresh annotation")
return nil
}
if r.NeedsStatusUpdate(refresh, expiry, acrBinding) {
log.Info("updating pull binding to reflect expiry and refresh time from secret")
return &action[O]{updatePullBindingStatus: r.UpdateStatus(refresh, expiry, acrBinding)}
}
// there's nothing for us to do, but we must make sure that we re-queue for a refresh
return &action[O]{noop: acrBinding}
}
func (a *action[O]) execute(ctx context.Context, logger logr.Logger, client crclient.Client, refresh func(O) time.Duration) (ctrl.Result, error) {
if a == nil {
return ctrl.Result{}, nil
}
a.validate()
if a.updatePullBinding != nil {
return ctrl.Result{}, client.Update(ctx, a.updatePullBinding)
} else if a.noop != nil {
after := clampRequeue(refresh(a.noop))
logger.WithValues("requeueAfter", after).Info("nothing to do, re-queueing for later processing")
return ctrl.Result{RequeueAfter: after}, nil
} else if a.updatePullBindingStatus != nil {
after := clampRequeue(refresh(a.updatePullBindingStatus))
logger.WithValues("requeueAfter", after).Info("re-queueing for later processing")
return ctrl.Result{RequeueAfter: after}, client.Status().Update(ctx, a.updatePullBindingStatus)
} else if a.createSecret != nil {
return ctrl.Result{}, client.Create(ctx, a.createSecret)
} else if a.updateSecret != nil {
return ctrl.Result{}, client.Update(ctx, a.updateSecret)
} else if a.deleteSecret != nil {
return ctrl.Result{}, client.Delete(ctx, a.deleteSecret)
} else if a.updateServiceAccount != nil {
return ctrl.Result{}, client.Update(ctx, a.updateServiceAccount)
}
logger.Info("no action taken")
return ctrl.Result{}, nil
}
// clampRequeue ensures that the requeue duration is greater than zero. Since we poll time.Now() more than once during
// reconciliation, it may be possible to have the following sets of events:
// t_refresh is when the credential should be refreshed based on our calculations
// 1. t_now = t_refresh - 1 : r.NeedsRefresh() determines nothing needs to be done
// 2. t_now = t_refresh + 1 : r.RequeueAfter() determines that requeue should have happened 1 unit in the past
//
// In such a case, ctrl.Result{} with RequeueAfter < 0 would not cause a requeue, and we would hang until the next
// re-list for the controller.
func clampRequeue(requeue time.Duration) time.Duration {
if requeue < 0 {
return time.Second
}
return requeue
}
type pullBinding interface {
*msiacrpullv1beta1.AcrPullBinding | *msiacrpullv1beta2.AcrPullBinding
crclient.Object
}
// action captures the outcome of a reconciliation pass using static data, to aid in testing the reconciliation loop
type action[O pullBinding] struct {
updatePullBinding O
noop O
updatePullBindingStatus O
createSecret *corev1.Secret
updateSecret *corev1.Secret
deleteSecret *corev1.Secret
updateServiceAccount *corev1.ServiceAccount
}
func (a *action[O]) validate() {
var present int
if a.updatePullBinding != nil {
present++
}
if a.noop != nil {
present++
}
if a.updatePullBindingStatus != nil {
present++
}
if a.createSecret != nil {
present++
}
if a.updateSecret != nil {
present++
}
if a.deleteSecret != nil {
present++
}
if a.updateServiceAccount != nil {
present++
}
if present > 1 {
panic("programmer error: more than one action specified in reconciliation loop")
}
}