Skip to content

Commit cdca8b6

Browse files
Merge pull request #309 from jsecchiero/main
feat: add syncOnResourceChange opt-in to sync the target secret on VaultSecret changes
2 parents cfe5773 + d426905 commit cdca8b6

6 files changed

Lines changed: 197 additions & 0 deletions

File tree

api/v1alpha1/vaultsecret_types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ type VaultSecretSpec struct {
3838
// +kubebuilder:validation:Required
3939
// +kubebuilder:default=90
4040
RefreshThreshold int `json:"refreshThreshold,omitempty"`
41+
// SyncOnResourceChange if set to true, the operator will immediately resync the secret from Vault
42+
// whenever the VaultSecret spec or metadata changes, bypassing the time-based refresh gate.
43+
// By default this is false, meaning changes to the resource will only take effect at the next scheduled refresh.
44+
// +kubebuilder:validation:Optional
45+
// +kubebuilder:default=false
46+
SyncOnResourceChange bool `json:"syncOnResourceChange,omitempty"`
4147
// VaultSecretDefinitions are the secrets in Vault.
4248
// +kubebuilder:validation:Required
4349
VaultSecretDefinitions []VaultSecretDefinition `json:"vaultSecretDefinitions,omitempty"`
@@ -60,6 +66,11 @@ type VaultSecretStatus struct {
6066
//NextVaultSecretUpdate the next time when this secret will be synced with Vault. If nil, it will not be refreshed.
6167
NextVaultSecretUpdate *metav1.Time `json:"nextVaultSecretUpdate,omitempty"`
6268

69+
//SyncedResourceVersion is a combination of the VaultSecret's generation and metadata hash.
70+
//Is enabled by SyncOnResourceChange: true
71+
// +kubebuilder:validation:Optional
72+
SyncedResourceVersion string `json:"syncedResourceVersion,omitempty"`
73+
6374
//VaultSecretDefinitionsStatus information used to determine if the secret should be rereconciled
6475
VaultSecretDefinitionsStatus []VaultSecretDefinitionStatus `json:"vaultSecretDefinitionsStatus,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
6576
}

config/crd/bases/redhatcop.redhat.io_vaultsecrets.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ spec:
8181
This is particularly useful for controlling when dynamic secrets should be refreshed before the lease duration is exceeded.
8282
The default is 90, meaning the secret would refresh after 90% of the time has passed from the vault secret's lease duration.
8383
type: integer
84+
syncOnResourceChange:
85+
default: false
86+
description: |-
87+
SyncOnResourceChange if set to true, the operator will immediately resync the secret from Vault
88+
whenever the VaultSecret spec or metadata changes, bypassing the time-based refresh gate.
89+
By default this is false, meaning changes to the resource will only take effect at the next scheduled refresh.
90+
type: boolean
8491
vaultSecretDefinitions:
8592
description: VaultSecretDefinitions are the secrets in Vault.
8693
items:
@@ -290,6 +297,11 @@ spec:
290297
will be synced with Vault. If nil, it will not be refreshed.
291298
format: date-time
292299
type: string
300+
syncedResourceVersion:
301+
description: |-
302+
SyncedResourceVersion is a combination of the VaultSecret's generation and metadata hash.
303+
Is is enabled by SyncOnResourceChange: true
304+
type: string
293305
vaultSecretDefinitionsStatus:
294306
description: VaultSecretDefinitionsStatus information used to determine
295307
if the secret should be rereconciled

controllers/vaultsecret_controller.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,20 @@ func toNamespacedName(obj metav1.Object) string {
259259

260260
func (r *VaultSecretReconciler) shouldSync(ctx context.Context, instance *redhatcopv1alpha1.VaultSecret) (bool, error) {
261261

262+
// if syncOnResourceChange is enabled and the VaultSecret spec or metadata has changed since the last sync,
263+
// always sync immediately.
264+
if instance.Spec.SyncOnResourceChange {
265+
currentResourceVersion := vaultsecretutils.GetResourceVersion(instance.ObjectMeta)
266+
if instance.Status.SyncedResourceVersion != currentResourceVersion {
267+
r.Log.V(1).Info("VaultSecret resource version changed, forcing sync",
268+
"namespacedName", toNamespacedName(instance),
269+
"syncedResourceVersion", instance.Status.SyncedResourceVersion,
270+
"currentResourceVersion", currentResourceVersion)
271+
return true, nil
272+
}
273+
}
274+
275+
// check if the k8s secret is valid (exists, owned, data not tampered with)
262276
secretNamespacedName := &types.NamespacedName{
263277
Name: instance.Spec.TemplatizedK8sSecret.Name,
264278
Namespace: instance.Namespace,
@@ -374,6 +388,7 @@ func (r *VaultSecretReconciler) manageSyncLogic(ctx context.Context, instance *r
374388

375389
now := metav1.NewTime(time.Now())
376390
instance.Status.LastVaultSecretUpdate = &now
391+
instance.Status.SyncedResourceVersion = vaultsecretutils.GetResourceVersion(instance.ObjectMeta)
377392
instance.Status.VaultSecretDefinitionsStatus = definitionsStatus
378393

379394
return nil

controllers/vaultsecretutils/hash.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package vaultsecretutils
33
import (
44
"crypto/sha256"
55
"encoding/hex"
6+
"fmt"
67
"sort"
8+
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
710
)
811

912
func hashHashes(l [32]byte, r [32]byte) [32]byte {
@@ -34,3 +37,17 @@ func HashData(data map[string][]byte) string {
3437

3538
return hex.EncodeToString(rootSha[:])
3639
}
40+
41+
// HashMeta returns a SHA-256 hash of the object's labels and annotations.
42+
func HashMeta(meta metav1.ObjectMeta) string {
43+
h := sha256.New()
44+
h.Write([]byte(fmt.Sprintf("%v", meta.Labels)))
45+
h.Write([]byte(fmt.Sprintf("%v", meta.Annotations)))
46+
return hex.EncodeToString(h.Sum(nil))
47+
}
48+
49+
// GetResourceVersion returns a string combining the object's generation and a hash of its metadata.
50+
// This is used to detect spec or metadata changes that should trigger an immediate resync.
51+
func GetResourceVersion(meta metav1.ObjectMeta) string {
52+
return fmt.Sprintf("%d-%s", meta.GetGeneration(), HashMeta(meta))
53+
}

controllers/vaultsecretutils/hash_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package vaultsecretutils
33
import (
44
"strings"
55
"testing"
6+
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
68
)
79

810
const (
@@ -101,3 +103,141 @@ func TestNil(t *testing.T) {
101103
t.Errorf("Unexpected Hash, got: %v, want: %v.", hash, emptyOrNilHash)
102104
}
103105
}
106+
107+
func TestHashMeta(t *testing.T) {
108+
meta := metav1.ObjectMeta{
109+
Labels: map[string]string{"app": "test"},
110+
Annotations: map[string]string{"note": "value"},
111+
}
112+
113+
hash := HashMeta(meta)
114+
115+
if hash == "" {
116+
t.Error("HashMeta returned empty string")
117+
}
118+
119+
// same input should produce same hash
120+
hash2 := HashMeta(meta)
121+
if hash != hash2 {
122+
t.Errorf("HashMeta not deterministic, got: %v and %v", hash, hash2)
123+
}
124+
}
125+
126+
func TestHashMetaDifferentLabels(t *testing.T) {
127+
meta1 := metav1.ObjectMeta{
128+
Labels: map[string]string{"app": "test"},
129+
}
130+
meta2 := metav1.ObjectMeta{
131+
Labels: map[string]string{"app": "other"},
132+
}
133+
134+
if HashMeta(meta1) == HashMeta(meta2) {
135+
t.Error("HashMeta should differ when labels differ")
136+
}
137+
}
138+
139+
func TestHashMetaDifferentAnnotations(t *testing.T) {
140+
meta1 := metav1.ObjectMeta{
141+
Annotations: map[string]string{"key": "value1"},
142+
}
143+
meta2 := metav1.ObjectMeta{
144+
Annotations: map[string]string{"key": "value2"},
145+
}
146+
147+
if HashMeta(meta1) == HashMeta(meta2) {
148+
t.Error("HashMeta should differ when annotations differ")
149+
}
150+
}
151+
152+
func TestHashMetaNilMaps(t *testing.T) {
153+
meta := metav1.ObjectMeta{}
154+
155+
hash := HashMeta(meta)
156+
157+
if hash == "" {
158+
t.Error("HashMeta returned empty string for nil labels/annotations")
159+
}
160+
}
161+
162+
func TestHashMetaEmptyMaps(t *testing.T) {
163+
meta1 := metav1.ObjectMeta{
164+
Labels: map[string]string{},
165+
Annotations: map[string]string{},
166+
}
167+
meta2 := metav1.ObjectMeta{}
168+
169+
// empty maps and nil maps should produce the same hash since fmt.Sprintf
170+
// renders both as "map[]"
171+
if HashMeta(meta1) != HashMeta(meta2) {
172+
t.Errorf("HashMeta should be equal for empty and nil maps, got: %v and %v", HashMeta(meta1), HashMeta(meta2))
173+
}
174+
}
175+
176+
func TestGetResourceVersion(t *testing.T) {
177+
meta := metav1.ObjectMeta{
178+
Generation: 1,
179+
Labels: map[string]string{"app": "test"},
180+
}
181+
182+
rv := GetResourceVersion(meta)
183+
184+
if rv == "" {
185+
t.Error("GetResourceVersion returned empty string")
186+
}
187+
188+
// should start with the generation number
189+
if rv[:2] != "1-" {
190+
t.Errorf("GetResourceVersion should start with generation, got: %v", rv)
191+
}
192+
}
193+
194+
func TestGetResourceVersionChangesOnGeneration(t *testing.T) {
195+
meta1 := metav1.ObjectMeta{
196+
Generation: 1,
197+
Labels: map[string]string{"app": "test"},
198+
}
199+
meta2 := metav1.ObjectMeta{
200+
Generation: 2,
201+
Labels: map[string]string{"app": "test"},
202+
}
203+
204+
rv1 := GetResourceVersion(meta1)
205+
rv2 := GetResourceVersion(meta2)
206+
207+
if rv1 == rv2 {
208+
t.Error("GetResourceVersion should differ when generation differs")
209+
}
210+
}
211+
212+
func TestGetResourceVersionChangesOnMetadata(t *testing.T) {
213+
meta1 := metav1.ObjectMeta{
214+
Generation: 1,
215+
Labels: map[string]string{"app": "test"},
216+
}
217+
meta2 := metav1.ObjectMeta{
218+
Generation: 1,
219+
Labels: map[string]string{"app": "changed"},
220+
}
221+
222+
rv1 := GetResourceVersion(meta1)
223+
rv2 := GetResourceVersion(meta2)
224+
225+
if rv1 == rv2 {
226+
t.Error("GetResourceVersion should differ when labels differ")
227+
}
228+
}
229+
230+
func TestGetResourceVersionStableWhenUnchanged(t *testing.T) {
231+
meta := metav1.ObjectMeta{
232+
Generation: 3,
233+
Labels: map[string]string{"app": "test"},
234+
Annotations: map[string]string{"note": "value"},
235+
}
236+
237+
rv1 := GetResourceVersion(meta)
238+
rv2 := GetResourceVersion(meta)
239+
240+
if rv1 != rv2 {
241+
t.Errorf("GetResourceVersion should be stable, got: %v and %v", rv1, rv2)
242+
}
243+
}

docs/secret-management.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Any manual data change or deletion of the K8s Secret owned by a VaultSecret CR w
7272

7373
- `refreshPeriod` the pull interval for syncing Vault secrets with the K8s Secret. This settings takes precedence over any lease duration returned by vault, effectively controlling when exactly all vault secrets defined in the vaultSecretDefinitions should re-sync.
7474
- `refreshThreshold` this is will instruct the operator to refresh the K8s Secret when a percentage of the lease duration has elapsed, if no `refreshPeriod` is specified. This is particularly useful for controlling when dynamic secrets should be refreshed before the lease duration is exceeded. The default is 90, meaning the secret would refresh after 90% of the time has passed from the vault secret's lease duration. When multiple vaultSecretDefinitions are defined, the smallest lease duration will be used when performing the scheduling for the next refresh.
75+
- `syncOnResourceChange` if set to `true`, the operator will immediately resync the secret from Vault whenever the VaultSecret spec or metadata (labels/annotations) changes, bypassing the time-based refresh gate. By default this is `false`, meaning changes to the VaultSecret resource will only take effect at the next scheduled refresh (controlled by `refreshPeriod` or `refreshThreshold`). This is useful when you want spec changes like updating `output.stringData` templates or `vaultSecretDefinitions` to be reflected in the K8s Secret right away without waiting for the next refresh cycle.
7576
- `vaultSecretDefinitions` is an array of Vault Secret References. Every `vaultSecretDefinition` has...
7677
- [authentication](#the-authentication-section) section.
7778
- `name` a unique name for the Vault secret to reference when templating, since many Vault secrets may have the same name.
@@ -94,6 +95,7 @@ metadata:
9495
name: randomsecret
9596
spec:
9697
refreshPeriod: 1m0s
98+
syncOnResourceChange: true
9799
vaultSecretDefinitions:
98100
- authentication:
99101
path: kubernetes

0 commit comments

Comments
 (0)