Skip to content
Open
Show file tree
Hide file tree
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
Expand Up @@ -53,3 +53,4 @@ node_modules/
.cursor
.envrc
mise.toml
/PLAN.md
17 changes: 14 additions & 3 deletions api/v1alpha1/secret_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,18 @@ type SecretSpec struct {
// +optional
Location string `json:"location"`

// the value should be base64 encoded
// Data contains text secret data.
// +optional
Data map[string]string `json:"data,omitempty"`

// SecretRef is the reference to the kubernetes secret
// BinaryData contains binary secret data. Values must be base64-encoded raw bytes.
// +kubebuilder:validation:XValidation:rule="self.all(k, self[k].matches('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'))",message="binaryData values must be valid base64"
// +optional
BinaryData map[string]string `json:"binaryData,omitempty"`

// SecretRef is the reference to the kubernetes secret.
// When SecretRef is set, it will be used to fetch the secret data.
// Data will be ignored.
// Data and BinaryData values set directly on this resource take precedence.
// +optional
SecretRef *KubernetesSecretReference `json:"secretRef,omitempty"`

Expand Down Expand Up @@ -110,6 +115,12 @@ func (r PoolMemberReference) ToNamespacedName() types.NamespacedName {
type KubernetesSecretReference struct {
Namespace string `json:"namespace" protobuf:"bytes,1,opt,name=namespace"`
Name string `json:"name" protobuf:"bytes,2,opt,name=name"`

// BinaryDataKeys are keys from the referenced Kubernetes Secret data that should be sent
// to StreamNative Cloud as binaryData. Other keys keep the existing text data behavior.
// +optional
// +listType=set
BinaryDataKeys []string `json:"binaryDataKeys,omitempty" protobuf:"bytes,3,rep,name=binaryDataKeys"`
}

func (r KubernetesSecretReference) ToNamespacedName() types.NamespacedName {
Expand Down
14 changes: 13 additions & 1 deletion api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,19 @@ spec:
type: string
type: object
x-kubernetes-map-type: atomic
binaryData:
additionalProperties:
type: string
description: BinaryData contains binary secret data. Values must be
base64-encoded raw bytes.
type: object
x-kubernetes-validations:
- message: binaryData values must be valid base64
rule: self.all(k, self[k].matches('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'))
data:
additionalProperties:
type: string
description: the value should be base64 encoded
description: Data contains text secret data.
type: object
instanceName:
description: InstanceName is the name of the instance this secret
Expand All @@ -105,10 +114,18 @@ spec:
type: string
secretRef:
description: |-
SecretRef is the reference to the kubernetes secret
SecretRef is the reference to the kubernetes secret.
When SecretRef is set, it will be used to fetch the secret data.
Data will be ignored.
Data and BinaryData values set directly on this resource take precedence.
properties:
binaryDataKeys:
description: |-
BinaryDataKeys are keys from the referenced Kubernetes Secret data that should be sent
to StreamNative Cloud as binaryData. Other keys keep the existing text data behavior.
items:
type: string
type: array
x-kubernetes-list-type: set
name:
type: string
namespace:
Expand Down
23 changes: 20 additions & 3 deletions config/crd/bases/resource.streamnative.io_secrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,19 @@ spec:
type: string
type: object
x-kubernetes-map-type: atomic
binaryData:
additionalProperties:
type: string
description: BinaryData contains binary secret data. Values must be
base64-encoded raw bytes.
type: object
x-kubernetes-validations:
- message: binaryData values must be valid base64
rule: self.all(k, self[k].matches('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'))
data:
additionalProperties:
type: string
description: the value should be base64 encoded
description: Data contains text secret data.
type: object
instanceName:
description: InstanceName is the name of the instance this secret
Expand All @@ -105,10 +114,18 @@ spec:
type: string
secretRef:
description: |-
SecretRef is the reference to the kubernetes secret
SecretRef is the reference to the kubernetes secret.
When SecretRef is set, it will be used to fetch the secret data.
Data will be ignored.
Data and BinaryData values set directly on this resource take precedence.
properties:
binaryDataKeys:
description: |-
BinaryDataKeys are keys from the referenced Kubernetes Secret data that should be sent
to StreamNative Cloud as binaryData. Other keys keep the existing text data behavior.
items:
type: string
type: array
x-kubernetes-list-type: set
name:
type: string
namespace:
Expand Down
2 changes: 2 additions & 0 deletions config/samples/resource_v1alpha1_secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@ spec:
type: Opaque
data:
key: value
binaryData:
keystore.p12: AAEC/w==
location: "useast1"
instanceName: "test-instance"
148 changes: 96 additions & 52 deletions controllers/secret_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package controllers

import (
"context"
"encoding/base64"
"fmt"
"time"

Expand Down Expand Up @@ -49,29 +50,95 @@ type SecretReconciler struct {
//+kubebuilder:rbac:groups=resource.streamnative.io,resources=streamnativecloudconnections,verbs=get;list;watch
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch

// getSecretData obtains the Secret data either from direct Data field or from SecretRef
func (r *SecretReconciler) getSecretData(ctx context.Context, secretCR *resourcev1alpha1.Secret) (map[string]string, *corev1.SecretType, error) {
// If direct data is provided, use it
if len(secretCR.Spec.Data) > 0 {
return secretCR.Spec.Data, secretCR.Spec.Type, nil
// resolveSecretRefData obtains Secret data from a referenced Kubernetes Secret.
func (r *SecretReconciler) resolveSecretRefData(
ctx context.Context,
secretCR *resourcev1alpha1.Secret,
) (map[string]string, map[string]string, *corev1.SecretType, error) {
if secretCR.Spec.SecretRef == nil {
return nil, nil, nil, fmt.Errorf("SecretRef is not specified in the Secret spec")
}

// If SecretRef is provided, fetch from the referenced Kubernetes Secret
if secretCR.Spec.SecretRef != nil {
nsName := secretCR.Spec.SecretRef.ToNamespacedName()
k8sSecret := &corev1.Secret{}
if err := r.Get(ctx, nsName, k8sSecret); err != nil {
return nil, nil, fmt.Errorf("failed to get referenced Secret %s/%s: %w", nsName.Namespace, nsName.Name, err)
nsName := secretCR.Spec.SecretRef.ToNamespacedName()
k8sSecret := &corev1.Secret{}
if err := r.Get(ctx, nsName, k8sSecret); err != nil {
return nil, nil, nil, fmt.Errorf("failed to get referenced Secret %s/%s: %w", nsName.Namespace, nsName.Name, err)
}

binaryDataKeys := make(map[string]struct{}, len(secretCR.Spec.SecretRef.BinaryDataKeys))
for _, key := range secretCR.Spec.SecretRef.BinaryDataKeys {
binaryDataKeys[key] = struct{}{}
}

stringData := make(map[string]string)
binaryData := make(map[string]string)
for key, value := range k8sSecret.Data {
if _, ok := binaryDataKeys[key]; ok {
binaryData[key] = base64.StdEncoding.EncodeToString(value)
continue
}
stringData[key] = string(value)
}

for key := range binaryDataKeys {
if _, ok := k8sSecret.Data[key]; !ok {
return nil, nil, nil, fmt.Errorf("binaryData key %q not found in referenced Secret %s/%s", key, nsName.Namespace, nsName.Name)
}
}

stringData := make(map[string]string)
for k, v := range k8sSecret.Data {
stringData[k] = string(v)
return stringData, binaryData, &k8sSecret.Type, nil
}

func validateSecretData(secretCR *resourcev1alpha1.Secret) error {
for key := range secretCR.Spec.Data {
if _, ok := secretCR.Spec.BinaryData[key]; ok {
return fmt.Errorf("secret data key %q is set in both spec.data and spec.binaryData", key)
}
return stringData, &k8sSecret.Type, nil
}
for key, value := range secretCR.Spec.BinaryData {
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
return fmt.Errorf("secret binaryData key %q must contain valid base64: %w", key, err)
}
}
return nil
}

return nil, nil, fmt.Errorf("neither Data nor SecretRef is specified in the Secret spec")
func copyStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}

func applyResolvedSecretRefData(
secretCR *resourcev1alpha1.Secret,
resolvedData map[string]string,
resolvedBinaryData map[string]string,
resolvedType *corev1.SecretType,
) {
directData := copyStringMap(secretCR.Spec.Data)
directBinaryData := copyStringMap(secretCR.Spec.BinaryData)
mergedData := copyStringMap(resolvedData)
mergedBinaryData := copyStringMap(resolvedBinaryData)

for key, value := range directData {
mergedData[key] = value
delete(mergedBinaryData, key)
}
for key, value := range directBinaryData {
mergedBinaryData[key] = value
delete(mergedData, key)
}

secretCR.Spec.Data = mergedData
secretCR.Spec.BinaryData = mergedBinaryData
if (secretCR.Spec.Type == nil || *secretCR.Spec.Type == "") && resolvedType != nil {
secretCR.Spec.Type = resolvedType
}
}

// Reconcile handles the reconciliation of Secret objects
Expand Down Expand Up @@ -102,6 +169,11 @@ func (r *SecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
return ctrl.Result{}, err // No requeue for permanent misconfiguration
}

if err := validateSecretData(secretCR); err != nil {
r.updateSecretStatus(ctx, secretCR, err, "ValidationFailed", err.Error())
return ctrl.Result{}, err
}

// Get StreamNativeCloudConnection
connection := &resourcev1alpha1.StreamNativeCloudConnection{}
connErr := r.Get(ctx, types.NamespacedName{
Expand Down Expand Up @@ -183,46 +255,18 @@ func (r *SecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
return ctrl.Result{Requeue: true}, nil
}

// Resolve secret data from Spec.Data or Spec.SecretRef
// The secretCR passed to cloud client methods should have its Spec.Data and Spec.Type populated.
currentSpecData := make(map[string]string)
for k, v := range secretCR.Spec.Data {
currentSpecData[k] = v
}
currentSpecType := secretCR.Spec.Type

// If SecretRef is used, we resolve it and update the CR if necessary.
// This ensures that the CR in etcd reflects the data being sent to the cloud API.
// Resolve data from SecretRef into an effective copy used for cloud sync.
// Do not persist resolved data back to the local CR; referenced Secret rotations must be reflected every reconcile.
effectiveSecretCR := secretCR
if secretCR.Spec.SecretRef != nil {
resolvedData, resolvedType, err := r.getSecretData(ctx, secretCR)
resolvedData, resolvedBinaryData, resolvedType, err := r.resolveSecretRefData(ctx, secretCR)
if err != nil {
r.updateSecretStatus(ctx, secretCR, err, "GetSecretDataFailed", fmt.Sprintf("Failed to get secret data from SecretRef: %v", err))
return ctrl.Result{}, err
}

// Check if an update to the local CR is needed
updateLocalCR := false
if len(secretCR.Spec.Data) == 0 { // Only populate from SecretRef if direct data is not set
secretCR.Spec.Data = resolvedData
updateLocalCR = true
}
// Only update type if original spec type was nil or empty, and resolvedType is not nil
if (secretCR.Spec.Type == nil || *secretCR.Spec.Type == "") && resolvedType != nil {
secretCR.Spec.Type = resolvedType
updateLocalCR = true
}

if updateLocalCR {
if err := r.Update(ctx, secretCR); err != nil {
// Restore original spec data before status update to avoid inconsistent state reporting
secretCR.Spec.Data = currentSpecData
secretCR.Spec.Type = currentSpecType
r.updateSecretStatus(ctx, secretCR, err, "UpdateLocalSecretFailed",
fmt.Sprintf("Failed to update local Secret CR with resolved data: %v", err))
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil // Requeue to use the updated CR
}
effectiveSecretCR = secretCR.DeepCopy()
applyResolvedSecretRefData(effectiveSecretCR, resolvedData, resolvedBinaryData, resolvedType)
}

existingRemoteSecret, err := secretClient.GetSecret(ctx, secretCR.Name)
Expand All @@ -235,13 +279,13 @@ func (r *SecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
}

if existingRemoteSecret == nil {
if _, err := secretClient.CreateSecret(ctx, secretCR); err != nil {
if _, err := secretClient.CreateSecret(ctx, effectiveSecretCR); err != nil {
r.updateSecretStatus(ctx, secretCR, err, "CreateRemoteSecretFailed", fmt.Sprintf("Failed to create remote Secret: %v", err))
return ctrl.Result{}, err
}
r.updateSecretStatus(ctx, secretCR, nil, "Ready", "Secret created and synced successfully")
} else {
if _, err := secretClient.UpdateSecret(ctx, secretCR); err != nil { // Pass the K8s CR
if _, err := secretClient.UpdateSecret(ctx, effectiveSecretCR); err != nil { // Pass the effective K8s CR
r.updateSecretStatus(ctx, secretCR, err, "UpdateRemoteSecretFailed", fmt.Sprintf("Failed to update remote Secret: %v", err))
return ctrl.Result{}, err
}
Expand Down
Loading
Loading