internal/controller/acrpullbinding_controller.go (284 lines of code) (raw):

package controller import ( "context" "crypto/sha256" "fmt" "math/big" "path" "slices" "strings" "time" msiacrpullv1beta1 "github.com/Azure/msi-acrpull/api/v1beta1" "github.com/Azure/msi-acrpull/pkg/authorizer" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) const ( // ACRPullBindingLabel is a label on Secrets that holds the name of the ACRPullBinding for which the Secret holds a pull credential ACRPullBindingLabel = "acr.microsoft.com/binding" // tokenExpiryAnnotation is an annotation on Secrets that records the TTL for a pull credential expiry, in time.RFC3339 format tokenExpiryAnnotation = "acr.microsoft.com/token.expiry" // tokenRefreshAnnotation is an annotation on Secrets that records the time a pull credential was refreshed, in time.RFC3339 format tokenRefreshAnnotation = "acr.microsoft.com/token.refresh" // tokenInputsAnnotation is an annotation on Secrets that records the inputs that were used to create the pull credential, for change detection tokenInputsAnnotation = "acr.microsoft.com/token.inputs" ownerKey = ".metadata.controller" dockerConfigKey = ".dockerconfigjson" msiAcrPullFinalizerName = "msi-acrpull.microsoft.com" defaultServiceAccountName = "default" tokenRefreshBuffer = time.Minute * 30 ) // V1beta1ReconcilerOpts configures the inputs for reconciling v1beta2 pull bindings type V1beta1ReconcilerOpts struct { CoreOpts Auth authorizer.Interface DefaultManagedIdentityResourceID string DefaultManagedIdentityClientID string DefaultACRServer string } func NewV1beta1Reconciler(opts *V1beta1ReconcilerOpts) *AcrPullBindingReconciler { if opts.now == nil { opts.now = time.Now } return &AcrPullBindingReconciler{ &genericReconciler[*msiacrpullv1beta1.AcrPullBinding]{ Client: opts.Client, Logger: opts.Logger, Scheme: opts.Scheme, NewBinding: func() *msiacrpullv1beta1.AcrPullBinding { return &msiacrpullv1beta1.AcrPullBinding{} }, AddFinalizer: func(binding *msiacrpullv1beta1.AcrPullBinding, finalizer string) *msiacrpullv1beta1.AcrPullBinding { updated := binding.DeepCopy() updated.ObjectMeta.Finalizers = append(updated.ObjectMeta.Finalizers, finalizer) return updated }, RemoveFinalizer: func(binding *msiacrpullv1beta1.AcrPullBinding, finalizer string) *msiacrpullv1beta1.AcrPullBinding { updated := binding.DeepCopy() updated.ObjectMeta.Finalizers = slices.DeleteFunc(updated.ObjectMeta.Finalizers, func(s string) bool { return s == finalizer }) return updated }, GetServiceAccountName: func(binding *msiacrpullv1beta1.AcrPullBinding) string { serviceAccountName := binding.Spec.ServiceAccountName if serviceAccountName == "" { serviceAccountName = defaultServiceAccountName } return serviceAccountName }, GetPullSecretName: func(binding *msiacrpullv1beta1.AcrPullBinding) string { return legacySecretName(binding.ObjectMeta.Name) }, GetInputsHash: func(binding *msiacrpullv1beta1.AcrPullBinding) string { msiClientID, msiResourceID, acrServer := specOrDefault(opts, binding.Spec) return base36sha224([]byte(msiClientID + msiResourceID + acrServer + binding.Spec.Scope)) }, CreatePullCredential: func(ctx context.Context, binding *msiacrpullv1beta1.AcrPullBinding, serviceAccount *corev1.ServiceAccount) (string, time.Time, error) { msiClientID, msiResourceID, acrServer := specOrDefault(opts, binding.Spec) acrAccessToken, err := opts.Auth.AcquireACRAccessToken(ctx, msiResourceID, msiClientID, acrServer, binding.Spec.Scope) if err != nil { return "", time.Time{}, fmt.Errorf("failed to retrieve ACR access token: %w", err) } dockerConfig, err := authorizer.CreateACRDockerCfg(acrServer, acrAccessToken) if err != nil { return "", time.Time{}, fmt.Errorf("failed to write ACR dockercfg: %v", err) } return dockerConfig, acrAccessToken.ExpiresOn, nil }, UpdateStatusError: func(binding *msiacrpullv1beta1.AcrPullBinding, s string) *msiacrpullv1beta1.AcrPullBinding { updated := binding.DeepCopy() updated.Status.Error = s return updated }, NeedsRefresh: func(logger logr.Logger, pullSecret *corev1.Secret, now func() time.Time) bool { return now().After(pullSecretExpiry(logger, pullSecret).Add(-1 * tokenRefreshBuffer)) }, RequeueAfter: func(now func() time.Time) func(binding *msiacrpullv1beta1.AcrPullBinding) time.Duration { return func(binding *msiacrpullv1beta1.AcrPullBinding) time.Duration { var requeueAfter time.Duration if binding.Status.TokenExpirationTime != nil { requeueAfter = binding.Status.TokenExpirationTime.Time.Add(-1 * tokenRefreshBuffer).Sub(now()) } return requeueAfter } }, NeedsStatusUpdate: func(refresh time.Time, expiry time.Time, binding *msiacrpullv1beta1.AcrPullBinding) bool { return binding.Status.Error != "" || binding.Status.TokenExpirationTime == nil || !binding.Status.TokenExpirationTime.Equal(&metav1.Time{Time: expiry}) || binding.Status.LastTokenRefreshTime == nil || !binding.Status.LastTokenRefreshTime.Equal(&metav1.Time{Time: refresh}) }, UpdateStatus: func(refresh time.Time, expiry time.Time, binding *msiacrpullv1beta1.AcrPullBinding) *msiacrpullv1beta1.AcrPullBinding { updated := binding.DeepCopy() updated.Status.TokenExpirationTime = &metav1.Time{Time: expiry} updated.Status.LastTokenRefreshTime = &metav1.Time{Time: refresh} updated.Status.Error = "" return updated }, now: opts.now, }, } } // AcrPullBindingReconciler reconciles a AcrPullBinding object type AcrPullBindingReconciler struct { *genericReconciler[*msiacrpullv1beta1.AcrPullBinding] } //+kubebuilder:rbac:groups=msi-acrpull.microsoft.com,resources=acrpullbindings,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=msi-acrpull.microsoft.com,resources=acrpullbindings/status,verbs=get;update;patch //+kubebuilder:rbac:groups=msi-acrpull.microsoft.com,resources=acrpullbindings/finalizers,verbs=update //+kubebuilder:rbac:groups="",resources=secrets,verbs=* //+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;update;patch func specOrDefault(opts *V1beta1ReconcilerOpts, spec msiacrpullv1beta1.AcrPullBindingSpec) (string, string, string) { msiClientID := spec.ManagedIdentityClientID msiResourceID := path.Clean(spec.ManagedIdentityResourceID) acrServer := spec.AcrServer if msiClientID == "" { msiClientID = opts.DefaultManagedIdentityClientID } if msiResourceID == "." { msiResourceID = opts.DefaultManagedIdentityResourceID } if acrServer == "" { acrServer = opts.DefaultACRServer } return msiClientID, msiResourceID, acrServer } // pullSecretExpiry determines when a pull credential stored in a Secret expires func pullSecretExpiry(log logr.Logger, secret *corev1.Secret) time.Time { return extractPullSecretTimeAnnotation(log, secret, tokenExpiryAnnotation) } // pullSecretRefresh determines when a pull credential stored in a Secret was last refreshed func pullSecretRefresh(log logr.Logger, secret *corev1.Secret) time.Time { return extractPullSecretTimeAnnotation(log, secret, tokenRefreshAnnotation) } // extractPullSecretTimeAnnotation extracts a timestamp from an annotation on the secret func extractPullSecretTimeAnnotation(log logr.Logger, secret *corev1.Secret, annotation string) time.Time { if secret == nil { return time.Time{} } formattedTime, annotated := secret.Annotations[annotation] if !annotated { return time.Time{} } timestamp, err := time.Parse(time.RFC3339, formattedTime) if err != nil { // we should never get into this state unless some other actor corrupts our annotation, // so we can consider this token expired and re-generate it to get back to a good state log.WithValues("secret", client.ObjectKeyFromObject(secret).String()).WithValues("annotation", annotation).Error(err, "unexpected error parsing annotation on secret") return time.Time{} } return timestamp } func (r *AcrPullBindingReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { if r.now == nil { r.now = time.Now } if err := mgr.GetFieldIndexer().IndexField(ctx, &msiacrpullv1beta1.AcrPullBinding{}, serviceAccountField, indexPullBindingByServiceAccount); err != nil { return err } if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1.Secret{}, pullBindingField, indexPullSecretByPullBinding); err != nil { return err } if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1.ServiceAccount{}, imagePullSecretsField, func(object client.Object) []string { serviceAccount, ok := object.(*corev1.ServiceAccount) if !ok { return nil } var imagePullSecrets []string for _, secretRef := range serviceAccount.ImagePullSecrets { if strings.HasPrefix(secretRef.Name, pullSecretNamePrefix) { imagePullSecrets = append(imagePullSecrets, secretRef.Name) } } return imagePullSecrets }); err != nil { return err } return ctrl.NewControllerManagedBy(mgr). For(&msiacrpullv1beta1.AcrPullBinding{}). Named("acr-pull-binding"). Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(enqueuePullBindingsForPullSecret(mgr))). Watches(&corev1.ServiceAccount{}, handler.EnqueueRequestsFromMapFunc(enqueuePullBindingsForServiceAccount(mgr))). Complete(r) } func indexPullSecretByPullBinding(object client.Object) []string { pullSecret, ok := object.(*corev1.Secret) if !ok { return nil } if pullBindingName, labelled := pullSecret.Labels[ACRPullBindingLabel]; labelled { return []string{pullBindingName} } // while we clean up legacy secrets and add labels to them, we need to handle un-labelled secrets here if isLegacySecretName(pullSecret.ObjectMeta.Name) { return []string{pullBindingNameFromLegacySecret(pullSecret.ObjectMeta.Name)} } return nil } func enqueuePullBindingsForPullSecret(_ ctrl.Manager) func(ctx context.Context, object client.Object) []reconcile.Request { return func(ctx context.Context, object client.Object) []reconcile.Request { pullSecret, ok := object.(*corev1.Secret) if !ok { return nil } var pullBindingName string if name, labelled := pullSecret.Labels[ACRPullBindingLabel]; labelled { pullBindingName = name } else if isLegacySecretName(pullSecret.ObjectMeta.Name) { pullBindingName = pullBindingNameFromLegacySecret(pullSecret.ObjectMeta.Name) } return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: pullSecret.Namespace, Name: pullBindingName}}} } } func getServiceAccountName(userSpecifiedName string) string { if userSpecifiedName != "" { return userSpecifiedName } return defaultServiceAccountName } func base36sha224(input []byte) string { // base36(sha224(value)) produces a useful, deterministic value that fits the requirements to be // a Kubernetes object name (honoring length requirement, is a valid DNS subdomain, etc) hash := sha256.Sum224(input) var i big.Int i.SetBytes(hash[:]) return i.Text(36) } const ( maxNameLength = 253 /* longest object name */ - 10 /* length of static content */ - 10 /* length of hash */ pullSecretNamePrefix = "acr-pull-" ) // pullSecretName generates a human-readable name that marks this secret as being a pull secret, while // ensuring that the name that's chosen will be a valid k8s Secret name, regardless of the input. // We want the common case to produce a name that's easy to determine a priori, since we expect users to // explicitly place the secret into their PodSpec. // Example validations for Secret names: // error: failed to create secret "..." is invalid: metadata.name: Invalid value: "...": must be no more than 253 characters // error: failed to create secret "..." is invalid: metadata.name: Invalid value: "...": a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*') func pullSecretName(acrBindingName string) string { suffix := acrBindingName if len(suffix) > maxNameLength { suffix = suffix[:maxNameLength] suffix = strings.TrimSuffix(suffix, ".") // trailing domain label separators can't be followed by '-' suffix = suffix + "-" + base36sha224([]byte(acrBindingName))[:10] } return pullSecretNamePrefix + suffix } func isSecretName(pullSecretName string) bool { return strings.HasPrefix(pullSecretName, pullSecretNamePrefix) } const legacyPullSecretSuffix = "-msi-acrpull-secret" func isLegacySecretName(pullSecretName string) bool { return strings.HasSuffix(pullSecretName, legacyPullSecretSuffix) } func pullBindingNameFromLegacySecret(pullSecretName string) string { return strings.TrimSuffix(pullSecretName, legacyPullSecretSuffix) } func legacySecretName(acrBindingName string) string { return acrBindingName + legacyPullSecretSuffix } func newPullSecret(acrBinding client.Object, name, dockerConfig string, scheme *runtime.Scheme, expiry time.Time, now func() time.Time, inputHash string) *corev1.Secret { pullSecret := &corev1.Secret{ Type: corev1.SecretTypeDockerConfigJson, ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ ACRPullBindingLabel: acrBinding.GetName(), }, Annotations: map[string]string{ tokenExpiryAnnotation: expiry.Format(time.RFC3339), tokenRefreshAnnotation: now().Format(time.RFC3339), tokenInputsAnnotation: inputHash, }, Name: name, Namespace: acrBinding.GetNamespace(), }, Data: map[string][]byte{ dockerConfigKey: []byte(dockerConfig), }, } if err := ctrl.SetControllerReference(acrBinding, pullSecret, scheme); err != nil { // ctrl.SetControllerReference can only error if the object already has an owner, and we're // creating this object from scratch so we know it cannot ever error, so handle this inline panic(fmt.Sprintf("programmer error: cannot set controller reference: %v", err)) } return pullSecret }