Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce external secret refresh capability #90

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -25,3 +25,4 @@ Dockerfile.cross
*.swo
*~
/_output/
.DS_Store
8 changes: 8 additions & 0 deletions api/v1alpha1/secretsync_types.go
Original file line number Diff line number Diff line change
@@ -118,6 +118,14 @@ type SecretSyncSpec struct {
// +kubebuilder:validation:Pattern=^[A-Za-z0-9]([-A-Za-z0-9]+([-._a-zA-Z0-9]?[A-Za-z0-9])*)?
// +optional
ForceSynchronization string `json:"forceSynchronization,omitempty"`

// RefreshInterval specifies the duration to wait between external secret refresh.
// If unspecified, the controller will not automatically refresh the secret.
// Minimum value is 10 seconds.
// +optional
// +kubebuilder:validation:Type=string
// +kubebuilder:validation:Pattern=^([0-9]+(\\.[0-9]+)?(s|m|h))+$
RefreshInterval *metav1.Duration `json:"refreshInterval,omitempty"`
}

// SecretSyncStatus defines the observed state of the secret synchronization process.
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.11.1
controller-gen.kubebuilder.io/version: v0.11.2
creationTimestamp: null
name: secretsyncs.secret-sync.x-k8s.io
spec:
@@ -47,6 +47,12 @@ spec:
maxLength: 253
pattern: ^[A-Za-z0-9]([-A-Za-z0-9]+([-._a-zA-Z0-9]?[A-Za-z0-9])*)?
type: string
refreshInterval:
description: RefreshInterval specifies the duration to wait between external secret refresh.
If unspecified, the controller will not automatically refresh the secret.
Minimum value is 10 seconds.
pattern: ^([0-9]+(\\.[0-9]+)?(s|m|h))+$
type: string
secretObject:
description: secretObject specifies the configuration for the synchronized
Kubernetes secret object.
7 changes: 7 additions & 0 deletions config/crd/bases/secret-sync.x-k8s.io_secretsyncs.yaml
Original file line number Diff line number Diff line change
@@ -52,6 +52,13 @@ spec:
maxLength: 253
pattern: ^[A-Za-z0-9]([-A-Za-z0-9]+([-._a-zA-Z0-9]?[A-Za-z0-9])*)?
type: string
refreshInterval:
description: |-
RefreshInterval specifies the duration to wait between external secret refresh.
If unspecified, the controller will not automatically refresh the secret.
Minimum value is 10 seconds.
pattern: ^([0-9]+(\\.[0-9]+)?(s|m|h))+$
type: string
secretObject:
description: secretObject specifies the configuration for the synchronized
Kubernetes secret object.
141 changes: 110 additions & 31 deletions internal/controller/secretsync_controller.go
Original file line number Diff line number Diff line change
@@ -23,7 +23,11 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"github.com/go-logr/logr"
"k8s.io/client-go/util/workqueue"
"os"
"reflect"
"sigs.k8s.io/controller-runtime/pkg/controller"
"slices"
"strings"
"time"
@@ -106,6 +110,7 @@ type SecretSyncReconciler struct {
//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
//+kubebuilder:rbac:groups=secrets-store.csi.x-k8s.io,resources=secretproviderclasses,verbs=get;list;watch

// Reconcile method includes requeue logic
func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
logger.Info("Reconciling SecretSync", "namespace=", req.NamespacedName.String())
@@ -117,6 +122,44 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return ctrl.Result{}, err
}

if ss.Spec.RefreshInterval == nil {
err := r.reconcileSecretSync(ctx, ss)
if err != nil {
logger.Error(err, "reconciliation error")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}

nextRefresh := calculateNextRefresh(ss)
requeueAfter := time.Until(nextRefresh)

// If we're past the refresh time, reconcile now
if requeueAfter < 0 {
err := r.reconcileSecretSync(ctx, ss)
if err != nil {
return ctrl.Result{Requeue: true, RequeueAfter: ss.Spec.RefreshInterval.Duration}, err
}
_ = r.updateLastSyncTime(ctx, ss, logger)
return ctrl.Result{RequeueAfter: ss.Spec.RefreshInterval.Duration}, nil
}

// Return with the calculated requeue duration
return ctrl.Result{Requeue: true, RequeueAfter: requeueAfter}, nil
}

func (r *SecretSyncReconciler) updateLastSyncTime(ctx context.Context, ss *secretsyncv1alpha1.SecretSync, logger logr.Logger) error {
ss.Status.LastSuccessfulSyncTime = &metav1.Time{Time: time.Now()}
if err := r.Status().Update(ctx, ss); err != nil {
logger.Error(err, "failed to update LastSuccessfulSyncTime")
return err
}
return nil
}

func (r *SecretSyncReconciler) reconcileSecretSync(ctx context.Context, ss *secretsyncv1alpha1.SecretSync) error {
logger := log.FromContext(ctx)

// update status conditions
r.updateStatusConditions(ctx, ss, "", ConditionTypeUnknown, ConditionReasonUnknown, false)

@@ -132,12 +175,12 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if err := secretutil.ValidateSecretObject(secretName, secretObj); err != nil {
logger.Error(err, "failed to validate secret object", "secretName", secretName)
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonUserInputValidationFailed, true)
return ctrl.Result{}, err
return err
}

labels, annotations, err := r.prepareLabelsAndAnnotations(ctx, secretObj, ss, conditionType)
if err != nil {
return ctrl.Result{}, err
return err
}

// get the service account token
@@ -152,15 +195,15 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)

r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, conditionReason, true)

return ctrl.Result{}, err
return err
}

// get the secret provider class object
spc := &secretsstorecsiv1.SecretProviderClass{}
if err := r.Get(ctx, client.ObjectKey{Name: ss.Spec.SecretProviderClassName, Namespace: req.Namespace}, spc); err != nil {
if err := r.Get(ctx, client.ObjectKey{Name: ss.Spec.SecretProviderClassName, Namespace: ss.Namespace}, spc); err != nil {
logger.Error(err, "failed to get secret provider class", "name", ss.Spec.SecretProviderClassName)
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonControllerSpcError, true)
return ctrl.Result{}, err
return err
}

// this is to mimic the parameters sent from CSI driver to the provider
@@ -171,7 +214,7 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)

parameters[CSIPodName] = os.Getenv(SyncControllerPodName)
parameters[CSIPodUID] = os.Getenv(SyncControllerPodUID)
parameters[CSIPodNamespace] = req.Namespace
parameters[CSIPodNamespace] = ss.Namespace
parameters[CSIPodServiceAccountName] = ss.Spec.ServiceAccountName

for k, v := range serviceAccountTokenAttrs {
@@ -182,15 +225,15 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if err != nil {
logger.Error(err, "failed to marshal parameters", "parameters", parameters)
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonControllerInternalError, true)
return ctrl.Result{}, err
return err
}

providerName := string(spc.Spec.Provider)
providerClient, err := r.ProviderClients.Get(ctx, providerName)
if err != nil {
logger.Error(err, "failed to get provider client", "provider", providerName)
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonControllerSpcError, true)
return ctrl.Result{}, err
return err
}

secretRefData := make(map[string]string)
@@ -199,7 +242,7 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if err != nil {
logger.Error(err, "failed to marshal secret")
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonControllerInternalError, true)
return ctrl.Result{}, err
return err
}

oldObjectVersions := make(map[string]string)
@@ -208,22 +251,22 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if err != nil {
logger.Error(err, "failed to get secrets from provider", "provider", providerName)
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonFailedProviderError, true)
return ctrl.Result{}, err
return err
}

secretType := secretutil.GetSecretType(strings.TrimSpace(secretObj.Type))
var datamap map[string][]byte
if datamap, err = secretutil.GetSecretData(secretObj.Data, secretType, files); err != nil {
logger.Error(err, "failed to get secret data", "secretName", secretName)
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonUserInputValidationFailed, true)
return ctrl.Result{}, err
return err
}

// Compute the hash of the secret
syncHash, err := r.computeSecretDataObjectHash(datamap, spc, ss)
if err != nil {
logger.Error(err, "failed to compute secret data object hash", "secretName", secretName)
return ctrl.Result{}, err
return err
}

// Check if the hash has changed.
@@ -240,7 +283,7 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)

if len(failedCondition.Type) == 0 && !hashChanged {
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonUpdateNoValueChangeSucceeded, true)
return ctrl.Result{}, nil
return nil
}

if conditionType == ConditionTypeCreate {
@@ -262,7 +305,7 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
}

// Attempt to create or update the secret.
if err = r.serverSidePatchSecret(ctx, ss, secretName, req.Namespace, datamap, objectVersions, labels, annotations, secretType); err != nil {
if err = r.serverSidePatchSecret(ctx, ss, secretName, ss.Namespace, datamap, objectVersions, labels, annotations, secretType); err != nil {
logger.Error(err, "failed to patch secret", "secretName", secretName)

// Rollback to the previous hash and the previous last successful sync time.
@@ -280,7 +323,7 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
r.updateStatusConditions(ctx, ss, ConditionTypeUnknown, conditionType, ConditionReasonSecretPatchFailedUnknownError, true)
}

return ctrl.Result{}, err
return err
}

// No errors found, remove the failed conditions.
@@ -293,11 +336,11 @@ func (r *SecretSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// Update the status.
err = r.Client.Status().Update(ctx, ss)
if err != nil {
return ctrl.Result{}, err
return err
}

logger.V(4).Info("Done... updated status", "syncHash", syncHash, "lastSuccessfulSyncTime", ss.Status.LastSuccessfulSyncTime)
return ctrl.Result{}, nil
return nil
}

func (r *SecretSyncReconciler) prepareLabelsAndAnnotations(
@@ -447,37 +490,73 @@ func (r *SecretSyncReconciler) computeSecretDataObjectHash(secretData map[string
return hmacHex, nil
}

// processIfSecretChanged checks if the secret sync object has changed.
func (r *SecretSyncReconciler) processIfSecretChanged(oldObj, newObj client.Object) bool {
ssOldObj := oldObj.(*secretsyncv1alpha1.SecretSync)
ssNewObj := newObj.(*secretsyncv1alpha1.SecretSync)

return ssNewObj.Status.SyncHash != ssOldObj.Status.SyncHash
}

// We need to trigger the reconcile function when the secret sync object is created or updated, however
// we don't need to trigger the reconcile function when the status of the secret sync object is updated.
// shouldReconcilePredicate controls when reconciliation should occur
func (r *SecretSyncReconciler) shouldReconcilePredicate() predicate.Funcs {
return predicate.Funcs{
CreateFunc: func(_ event.CreateEvent) bool {
return true
},
UpdateFunc: func(e event.UpdateEvent) bool {
return r.processIfSecretChanged(e.ObjectOld, e.ObjectNew)
},
DeleteFunc: func(_ event.DeleteEvent) bool {
return false
},
GenericFunc: func(_ event.GenericEvent) bool {
return true
return false
},
UpdateFunc: func(e event.UpdateEvent) bool {
oldSS := e.ObjectOld.(*secretsyncv1alpha1.SecretSync)
newSS := e.ObjectNew.(*secretsyncv1alpha1.SecretSync)

// Process if:
// 1. Spec changed (user updated the resource)
if !reflect.DeepEqual(oldSS.Spec, newSS.Spec) {
return true
}

// 2. Status.SyncHash was cleared (indicating refresh needed)
if oldSS.Status.SyncHash != "" && newSS.Status.SyncHash == "" {
return true
}

// 3. If LastSuccessfulSyncTime indicates refresh is due
if newSS.Spec.RefreshInterval != nil {
nextRefresh := calculateNextRefresh(newSS)
if time.Now().After(nextRefresh) {
return true
}
}

// Otherwise, skip reconciliation
return false
},
}
}

// calculateNextRefresh determines when the next refresh should occur
func calculateNextRefresh(ss *secretsyncv1alpha1.SecretSync) time.Time {
// If no successful sync yet, refresh should happen immediately
if ss.Status.LastSuccessfulSyncTime == nil {
return time.Now()
}

// If no refresh interval set, no refresh needed
if ss.Spec.RefreshInterval == nil {
return time.Time{} // Zero time indicates no refresh needed
}

// Calculate next refresh time based on last successful sync plus interval
return ss.Status.LastSuccessfulSyncTime.Add(ss.Spec.RefreshInterval.Duration)
}

// SetupWithManager sets up the controller with the Manager.
func (r *SecretSyncReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&secretsyncv1alpha1.SecretSync{}).
WithEventFilter(r.shouldReconcilePredicate()).
WithOptions(controller.Options{
RateLimiter: workqueue.NewItemExponentialFailureRateLimiter(
5*time.Second, // base delay
10*time.Minute, // max delay
),
}).
Complete(r)
}