diff --git a/Makefile b/Makefile index 054ddd2d..36728bfe 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,7 @@ build-examples: ## Build all example binaries. run-examples: ## Run all examples to verify they execute without error. go run ./examples/deployment-primitive/. go run ./examples/configmap-primitive/. + go run ./examples/secret-primitive/. go run ./examples/statefulset-primitive/. go run ./examples/replicaset-primitive/. go run ./examples/rolebinding-primitive/. diff --git a/docs/primitives.md b/docs/primitives.md index a90f97ea..5142aa4a 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -114,22 +114,23 @@ This design: Editors provide scoped, typed APIs for modifying specific parts of a resource: -| Editor | Scope | -| ------------------------------- | --------------------------------------------------------------------------------- | -| `ContainerEditor` | Environment variables, arguments, resource limits, ports | -| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | -| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | -| `StatefulSetSpecEditor` | Replicas, service name, pod management policy, update strategy | -| `ReplicaSetSpecEditor` | Replicas, min ready seconds | -| `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit | -| `PodDisruptionBudgetSpecEditor` | MinAvailable, MaxUnavailable, selector, eviction policy | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | -| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access | -| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw | -| `PVSpecEditor` | PV spec fields — capacity, access modes, reclaim policy, storage class | -| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests | -| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration | -| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | +| Editor | Scope | +| ------------------------------- | --------------------------------------------------------------------------------------------------- | +| `ContainerEditor` | Environment variables, arguments, resource limits, ports | +| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | +| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `StatefulSetSpecEditor` | Replicas, service name, pod management policy, update strategy | +| `ReplicaSetSpecEditor` | Replicas, min ready seconds | +| `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit | +| `PodDisruptionBudgetSpecEditor` | MinAvailable, MaxUnavailable, selector, eviction policy | +| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| `SecretDataEditor` | `.data` and `.stringData` — set/remove bytes, `SetString`/`RemoveString`, `Raw()`/`RawStringData()` | +| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access | +| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw | +| `PVSpecEditor` | PV spec fields — capacity, access modes, reclaim policy, storage class | +| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests | +| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration | +| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. @@ -160,6 +161,7 @@ have been applied. This means a single mutation can safely add a container and t | `pkg/primitives/job` | Task | [job.md](primitives/job.md) | | `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/secret` | Static | [secret.md](primitives/secret.md) | | `pkg/primitives/pdb` | Static | [pdb.md](primitives/pdb.md) | | `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) | | `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md new file mode 100644 index 00000000..42d4a694 --- /dev/null +++ b/docs/primitives/secret.md @@ -0,0 +1,296 @@ +# Secret Primitive + +The `secret` primitive is the framework's built-in static abstraction for managing Kubernetes `Secret` resources. It +integrates with the component lifecycle and provides a structured mutation API for managing `.data` and `.stringData` +entries and object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | ------------------------------------------------------------------------------------------------ | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch | +| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle | + +## Building a Secret Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" + +base := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-credentials", + Namespace: owner.Namespace, + }, + Data: map[string][]byte{ + "password": []byte("default-password"), + }, +} + +resource, err := secret.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `Secret` beyond its baseline. Each mutation is a named function that +receives a `*Mutator` and records edit intent through typed editors. + +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature +with no version constraints and no `When()` conditions is also always enabled: + +```go +func MyFeatureMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *secret.Mutator) error { + m.SetData("feature-flag", []byte("enabled")) + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. + +### Boolean-gated mutations + +```go +func TLSSecretMutation(version string, tlsEnabled bool) secret.Mutation { + return secret.Mutation{ + Name: "tls-secret", + Feature: feature.NewResourceFeature(version, nil).When(tlsEnabled), + Mutate: func(m *secret.Mutator) error { + m.SetData("tls.crt", certBytes) + m.SetData("tls.key", keyBytes) + return nil + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyTokenMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "legacy-token", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("auth-mode", "legacy-token") + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are +recorded: + +| Step | Category | What it affects | +| ---- | -------------- | ---------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `Secret` | +| 2 | Data edits | `.data` and `.stringData` entries — Set, Remove, Raw | + +Within each category, edits are applied in their registration order. Later edits in the same mutation observe the Secret +as modified by all earlier edits. + +## Editors + +### SecretDataEditor + +The primary API for modifying `.data` and `.stringData` entries. Use `m.EditData` for full control: + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.Set("password", []byte("new-password")) + e.Remove("stale-key") + e.SetString("config-value", "plaintext") + return nil +}) +``` + +#### Set and Remove (.data) + +`Set` adds or overwrites a `.data` key with a byte slice value. `Remove` deletes a `.data` key; it is a no-op if the key +is absent. + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.Set("api-key", []byte("secret-value")) + e.Remove("deprecated-key") + return nil +}) +``` + +#### SetString and RemoveString (.stringData) + +`SetString` adds or overwrites a `.stringData` key with a plaintext value. The API server merges `.stringData` into +`.data` on write. `RemoveString` deletes a `.stringData` key; it is a no-op if the key is absent. + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.SetString("username", "admin") + e.RemoveString("old-username") + return nil +}) +``` + +#### Raw Escape Hatches + +`Raw()` returns the underlying `map[string][]byte` for `.data`. `RawStringData()` returns the underlying +`map[string]string` for `.stringData`. Both give direct access for free-form editing when none of the structured methods +are sufficient: + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + raw := e.Raw() + for k, v := range externalDefaults { + if _, exists := raw[k]; !exists { + raw[k] = v + } + } + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("checksum/secret", secretHash) + return nil +}) +``` + +## Convenience Methods + +The `Mutator` exposes convenience wrappers for the most common `.data` and `.stringData` operations: + +| Method | Equivalent to | +| --------------------------- | -------------------------------------- | +| `SetData(key, value)` | `EditData` → `e.Set(key, value)` | +| `RemoveData(key)` | `EditData` → `e.Remove(key)` | +| `SetStringData(key, value)` | `EditData` → `e.SetString(key, value)` | +| `RemoveStringData(key)` | `EditData` → `e.RemoveString(key)` | + +Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a +single edit block. + +## Data Hash + +Two utilities are provided for computing a stable SHA-256 hash of a Secret's effective data content (`.data` plus +`.stringData` merged using Kubernetes API-server semantics). A common use is to annotate a Deployment's pod template +with this hash so that a secret change triggers a rolling restart. + +### DataHash + +`DataHash` hashes a Secret value you already have — for example, one read from the cluster: + +```go +hash, err := secret.DataHash(s) +``` + +The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically, so it is +deterministic regardless of insertion order. Both `.data` and `.stringData` are included: `.stringData` entries are +merged into a copy of `.data` (with `.stringData` keys taking precedence) before hashing, matching Kubernetes API-server +write semantics. This ensures the hash is consistent whether called on a desired object (which may use `.stringData`) or +a cluster-read object (where `.stringData` has already been merged into `.data`). + +### Resource.DesiredHash + +`DesiredHash` computes the hash of what the operator _will write_ — that is, the base object with all registered +mutations applied — without performing a cluster read and without a second reconcile cycle: + +```go +secretResource, err := secret.NewBuilder(base). + WithMutation(BaseSecretMutation(owner.Spec.Version)). + WithMutation(TLSMutation(owner.Spec.EnableTLS)). + Build() + +hash, err := secretResource.DesiredHash() +``` + +The hash covers only operator-controlled fields. Only changes to operator-owned content will change the hash. + +### Annotating a Deployment pod template (single-pass pattern) + +Build the secret resource first, compute the hash, then pass it into the deployment resource factory. Both resources are +registered with the same component, so the secret is reconciled first and the deployment sees the correct hash on every +cycle. + +`DesiredHash` is defined on `*secret.Resource`, not on the `component.Resource` interface, so keep the concrete type +when you need to call it: + +```go +secretResource, err := secret.NewBuilder(base). + WithMutation(features.BaseSecretMutation(owner.Spec.Version)). + WithMutation(features.TLSMutation(owner.Spec.Version, owner.Spec.EnableTLS)). + Build() +if err != nil { + return err +} + +hash, err := secretResource.DesiredHash() +if err != nil { + return err +} + +deployResource, err := resources.NewDeploymentResource(owner, hash) +if err != nil { + return err +} + +comp, err := component.NewComponentBuilder(). + WithResource(secretResource, component.ResourceOptions{}). // reconciled first + WithResource(deployResource, component.ResourceOptions{}). + Build() +``` + +```go +// In NewDeploymentResource, use the hash in a mutation: +func ChecksumAnnotationMutation(version, secretHash string) deployment.Mutation { + return deployment.Mutation{ + Name: "secret-checksum", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *deployment.Mutator) error { + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("checksum/secret", secretHash) + return nil + }) + return nil + }, + } +} +``` + +When the secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same +reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. + +**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. + +**Prefer `.stringData` for human-readable values.** The API server handles base64 encoding; using `SetStringData` avoids +manual encoding in mutation code. diff --git a/examples/secret-primitive/README.md b/examples/secret-primitive/README.md new file mode 100644 index 00000000..c87f2144 --- /dev/null +++ b/examples/secret-primitive/README.md @@ -0,0 +1,31 @@ +# Secret Primitive Example + +This example demonstrates the usage of the `secret` primitive within the operator component framework. It shows how to +manage a Kubernetes Secret as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a Secret with basic metadata and type. +- **Feature Mutations**: Composing secret entries from independent, feature-gated mutations using `SetStringData`. +- **Metadata Mutations**: Setting version labels on the Secret via `EditObjectMetadata`. +- **Data Extraction**: Harvesting Secret entries after each reconcile cycle. + +## Directory Structure + +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from + `examples/shared/app`. +- `features/`: Contains modular feature definitions: + - `mutations.go`: base credentials, version labelling, and feature-gated tracing and metrics tokens. +- `resources/`: Contains the central `NewSecretResource` factory that assembles all features using `secret.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/secret-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile through four spec variations, printing the secret entries after each cycle. +4. Print the resulting status conditions. diff --git a/examples/secret-primitive/app/controller.go b/examples/secret-primitive/app/controller.go new file mode 100644 index 00000000..339befb3 --- /dev/null +++ b/examples/secret-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the secret primitive. +package app + +import ( + "context" + + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewSecretResource is a factory function to create the secret resource. + // This allows us to inject the resource construction logic. + NewSecretResource func(*sharedapp.ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *sharedapp.ExampleApp) error { + // 1. Build the secret resource for this owner. + secretResource, err := r.NewSecretResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the secret. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(secretResource, component.ResourceOptions{}). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/secret-primitive/features/mutations.go b/examples/secret-primitive/features/mutations.go new file mode 100644 index 00000000..a6b007e5 --- /dev/null +++ b/examples/secret-primitive/features/mutations.go @@ -0,0 +1,67 @@ +// Package features provides sample mutations for the secret primitive example. +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" +) + +// BaseCredentialsMutation writes the application's core credentials into the Secret. +// It is always enabled. +// +// NOTE: Real controllers must never hard-code credentials. Source them from +// external secret stores, environment variables, or operator CR fields. +func BaseCredentialsMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "base-credentials", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("username", "REPLACE_ME") + m.SetStringData("password", "REPLACE_ME") + return nil + }, + } +} + +// VersionLabelMutation sets the app.kubernetes.io/version label on the Secret. +// It is always enabled. +func VersionLabelMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *secret.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// TracingTokenMutation adds an OpenTelemetry tracing auth token to the Secret. +// It is enabled when enableTracing is true. +func TracingTokenMutation(version string, enableTracing bool) secret.Mutation { + return secret.Mutation{ + Name: "tracing-token", + Feature: feature.NewResourceFeature(version, nil).When(enableTracing), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("otel-auth-token", "REPLACE_ME") + return nil + }, + } +} + +// MetricsTokenMutation adds a Prometheus remote-write auth token to the Secret. +// It is enabled when enableMetrics is true. +func MetricsTokenMutation(version string, enableMetrics bool) secret.Mutation { + return secret.Mutation{ + Name: "metrics-token", + Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("metrics-auth-token", "REPLACE_ME") + return nil + }, + } +} diff --git a/examples/secret-primitive/main.go b/examples/secret-primitive/main.go new file mode 100644 index 00000000..0902ef74 --- /dev/null +++ b/examples/secret-primitive/main.go @@ -0,0 +1,115 @@ +// Package main is the entry point for the secret primitive example. +package main + +import ( + "context" + "fmt" + "os" + + ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" + "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client. + scheme := runtime.NewScheme() + if err := sharedapp.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := corev1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add core/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&sharedapp.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize the controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + NewSecretResource: resources.NewSecretResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate how + // feature-gated mutations compose secret entries. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: false, // Disable metrics too + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Tracing=%v, Metrics=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics) + + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/secret-primitive/resources/secret.go b/examples/secret-primitive/resources/secret.go new file mode 100644 index 00000000..eb2d1e06 --- /dev/null +++ b/examples/secret-primitive/resources/secret.go @@ -0,0 +1,55 @@ +// Package resources provides resource implementations for the secret primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/features" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewSecretResource constructs a secret primitive resource with all the features. +func NewSecretResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base Secret object. + base := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-credentials", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Type: corev1.SecretTypeOpaque, + } + + // 2. Initialize the secret builder. + builder := secret.NewBuilder(base) + + // 3. Register mutations in dependency order. + // + // BaseCredentialsMutation and VersionLabelMutation always run first to establish + // the baseline. Tracing and metrics tokens are then added on top. + builder.WithMutation(features.BaseCredentialsMutation(owner.Spec.Version)) + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.TracingTokenMutation(owner.Spec.Version, owner.Spec.EnableTracing)) + builder.WithMutation(features.MetricsTokenMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Extract data from the reconciled Secret (only the persisted Data field is observable). + // + // NOTE: Never log secret values in production controllers. This extractor + // prints only key names and value lengths to avoid leaking credentials. + builder.WithDataExtractor(func(s corev1.Secret) error { + fmt.Printf("Reconciled Secret: %s\n", s.Name) + for key, value := range s.Data { + fmt.Printf(" [%s]: %d bytes\n", key, len(value)) + } + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/secretdata.go b/pkg/mutation/editors/secretdata.go new file mode 100644 index 00000000..e0534e79 --- /dev/null +++ b/pkg/mutation/editors/secretdata.go @@ -0,0 +1,96 @@ +package editors + +// SecretDataEditor provides a typed API for mutating the .data and .stringData +// fields of a Kubernetes Secret. +// +// It exposes structured operations (Set, Remove, SetString, RemoveString) as +// well as Raw() and RawStringData() for free-form access when none of the +// structured methods are sufficient. +// +// Note on Kubernetes semantics: the API server merges .stringData into .data +// on write and returns only .data on read. During reconciliation it is safe to +// use either field; the two coexist on the desired object until it is applied. +type SecretDataEditor struct { + data *map[string][]byte + stringData *map[string]string +} + +// NewSecretDataEditor creates a new SecretDataEditor wrapping the given .data +// and .stringData map pointers. +// +// Either pointer may be nil, in which case the editor allocates a local +// zero-value map for that field. Operations on that field will succeed but +// writes will not propagate back to any external map. Pass non-nil pointers +// (e.g. &secret.Data, &secret.StringData) when the changes must be reflected +// on the object. The maps the pointers refer to may themselves be nil; methods +// that write to a map initialise it automatically. +func NewSecretDataEditor(data *map[string][]byte, stringData *map[string]string) *SecretDataEditor { + if data == nil { + var d map[string][]byte + data = &d + } + if stringData == nil { + var sd map[string]string + stringData = &sd + } + return &SecretDataEditor{data: data, stringData: stringData} +} + +// Raw returns the underlying .data map directly, initialising it if necessary. +// +// This is an escape hatch for free-form editing when none of the structured +// methods are sufficient. +func (e *SecretDataEditor) Raw() map[string][]byte { + if *e.data == nil { + *e.data = make(map[string][]byte) + } + return *e.data +} + +// RawStringData returns the underlying .stringData map directly, initialising +// it if necessary. +// +// This is an escape hatch for free-form editing. +func (e *SecretDataEditor) RawStringData() map[string]string { + if *e.stringData == nil { + *e.stringData = make(map[string]string) + } + return *e.stringData +} + +// Set sets key to value in .data, initialising the map if necessary. +func (e *SecretDataEditor) Set(key string, value []byte) { + if *e.data == nil { + *e.data = make(map[string][]byte) + } + (*e.data)[key] = value +} + +// Remove deletes key from .data. It is a no-op if the key does not exist +// or the underlying map is nil. +func (e *SecretDataEditor) Remove(key string) { + if *e.data == nil { + return + } + delete(*e.data, key) +} + +// SetString sets key to value in .stringData, initialising the map if necessary. +// +// The API server will merge .stringData into .data on write; use this when +// working with plaintext values that should not be pre-encoded. +func (e *SecretDataEditor) SetString(key, value string) { + if *e.stringData == nil { + *e.stringData = make(map[string]string) + } + (*e.stringData)[key] = value +} + +// RemoveString deletes key from .stringData. It is a no-op if the key does not exist +// or the underlying map is nil. +func (e *SecretDataEditor) RemoveString(key string) { + if *e.stringData == nil { + return + } + delete(*e.stringData, key) +} diff --git a/pkg/mutation/editors/secretdata_test.go b/pkg/mutation/editors/secretdata_test.go new file mode 100644 index 00000000..d37f3595 --- /dev/null +++ b/pkg/mutation/editors/secretdata_test.go @@ -0,0 +1,127 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- NewSecretDataEditor --- + +func TestNewSecretDataEditor_NilPointers(t *testing.T) { + // Nil pointers must not panic; operations succeed but writes are local only. + e := NewSecretDataEditor(nil, nil) + require.NotNil(t, e) + e.Set("k", []byte("v")) + e.SetString("k", "v") +} + +// --- Set and Remove (.data) --- + +func TestSecretDataEditor_Set(t *testing.T) { + data := map[string][]byte{} + e := NewSecretDataEditor(&data, nil) + e.Set("key", []byte("value")) + assert.Equal(t, []byte("value"), data["key"]) +} + +func TestSecretDataEditor_Set_InitialisesNilMap(t *testing.T) { + var data map[string][]byte + e := NewSecretDataEditor(&data, nil) + e.Set("key", []byte("value")) + require.NotNil(t, data) + assert.Equal(t, []byte("value"), data["key"]) +} + +func TestSecretDataEditor_Set_Overwrites(t *testing.T) { + data := map[string][]byte{"key": []byte("old")} + e := NewSecretDataEditor(&data, nil) + e.Set("key", []byte("new")) + assert.Equal(t, []byte("new"), data["key"]) +} + +func TestSecretDataEditor_Remove(t *testing.T) { + data := map[string][]byte{"key": []byte("v"), "other": []byte("keep")} + e := NewSecretDataEditor(&data, nil) + e.Remove("key") + assert.NotContains(t, data, "key") + assert.Equal(t, []byte("keep"), data["other"]) +} + +func TestSecretDataEditor_Remove_MissingKey(t *testing.T) { + data := map[string][]byte{"other": []byte("keep")} + e := NewSecretDataEditor(&data, nil) + e.Remove("missing") + assert.Equal(t, []byte("keep"), data["other"]) +} + +// --- SetString and RemoveString (.stringData) --- + +func TestSecretDataEditor_SetString(t *testing.T) { + sd := map[string]string{} + e := NewSecretDataEditor(nil, &sd) + e.SetString("key", "value") + assert.Equal(t, "value", sd["key"]) +} + +func TestSecretDataEditor_SetString_InitialisesNilMap(t *testing.T) { + var sd map[string]string + e := NewSecretDataEditor(nil, &sd) + e.SetString("key", "value") + require.NotNil(t, sd) + assert.Equal(t, "value", sd["key"]) +} + +func TestSecretDataEditor_RemoveString(t *testing.T) { + sd := map[string]string{"key": "v", "other": "keep"} + e := NewSecretDataEditor(nil, &sd) + e.RemoveString("key") + assert.NotContains(t, sd, "key") + assert.Equal(t, "keep", sd["other"]) +} + +func TestSecretDataEditor_RemoveString_MissingKey(t *testing.T) { + sd := map[string]string{"other": "keep"} + e := NewSecretDataEditor(nil, &sd) + e.RemoveString("missing") + assert.Equal(t, "keep", sd["other"]) +} + +// --- Raw and RawStringData --- + +func TestSecretDataEditor_Raw(t *testing.T) { + data := map[string][]byte{"existing": []byte("keep")} + e := NewSecretDataEditor(&data, nil) + raw := e.Raw() + raw["new"] = []byte("added") + assert.Equal(t, []byte("keep"), data["existing"]) + assert.Equal(t, []byte("added"), data["new"]) +} + +func TestSecretDataEditor_Raw_InitialisesNilMap(t *testing.T) { + var data map[string][]byte + e := NewSecretDataEditor(&data, nil) + raw := e.Raw() + require.NotNil(t, raw) + raw["k"] = []byte("v") + assert.Equal(t, []byte("v"), data["k"]) +} + +func TestSecretDataEditor_RawStringData(t *testing.T) { + sd := map[string]string{"existing": "keep"} + e := NewSecretDataEditor(nil, &sd) + raw := e.RawStringData() + raw["new"] = "added" + assert.Equal(t, "keep", sd["existing"]) + assert.Equal(t, "added", sd["new"]) +} + +func TestSecretDataEditor_RawStringData_InitialisesNilMap(t *testing.T) { + var sd map[string]string + e := NewSecretDataEditor(nil, &sd) + raw := e.RawStringData() + require.NotNil(t, raw) + raw["k"] = "v" + assert.Equal(t, "v", sd["k"]) +} diff --git a/pkg/primitives/secret/builder.go b/pkg/primitives/secret/builder.go new file mode 100644 index 00000000..f2e32ce1 --- /dev/null +++ b/pkg/primitives/secret/builder.go @@ -0,0 +1,79 @@ +package secret + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + corev1 "k8s.io/api/core/v1" +) + +// Builder is a configuration helper for creating and customizing a Secret Resource. +// +// It provides a fluent API for registering mutations and data extractors. +// Build() validates the configuration and returns an initialized Resource +// ready for use in a reconciliation loop. +type Builder struct { + base *generic.StaticBuilder[*corev1.Secret, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Secret object. +// +// The Secret object serves as the desired base state. During reconciliation +// the Resource will make the cluster's state match this base, modified by any +// registered mutations. +// +// The provided Secret must have both Name and Namespace set, which is validated +// during the Build() call. +func NewBuilder(s *corev1.Secret) *Builder { + identityFunc := func(secret *corev1.Secret) string { + return fmt.Sprintf("v1/Secret/%s/%s", secret.Namespace, secret.Name) + } + + return &Builder{ + base: generic.NewStaticBuilder[*corev1.Secret, *Mutator]( + s, + identityFunc, + NewMutator, + ), + } +} + +// WithMutation registers a mutation for the Secret. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithDataExtractor registers a function to read values from the Secret after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled Secret. This is useful +// for surfacing generated or updated entries to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(corev1.Secret) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(s *corev1.Secret) error { + return extractor(*s) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Secret object was provided. +// - The Secret is missing a Name or Namespace. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/secret/builder_test.go b/pkg/primitives/secret/builder_test.go new file mode 100644 index 00000000..3d8b7bc9 --- /dev/null +++ b/pkg/primitives/secret/builder_test.go @@ -0,0 +1,122 @@ +package secret + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder_Build_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + secret *corev1.Secret + expectedErr string + }{ + { + name: "nil secret", + secret: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.secret).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "v1/Secret/test-ns/test-secret", res.Identity()) + } + }) + } +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + res, err := NewBuilder(s). + WithMutation(Mutation{Name: "test-mutation"}). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) +} + +func TestBuilder_WithDataExtractor(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + called := false + extractor := func(_ corev1.Secret) error { + called = true + return nil + } + res, err := NewBuilder(s). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&corev1.Secret{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + res, err := NewBuilder(s). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + res, err := NewBuilder(s). + WithDataExtractor(func(_ corev1.Secret) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&corev1.Secret{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/secret/hash.go b/pkg/primitives/secret/hash.go new file mode 100644 index 00000000..ced1c9a7 --- /dev/null +++ b/pkg/primitives/secret/hash.go @@ -0,0 +1,85 @@ +package secret + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" +) + +// DataHash computes a stable SHA-256 hash of the effective data content of the +// given Secret. +// +// The hash is derived from the canonical JSON encoding of the merged data map +// with keys sorted alphabetically, so it is deterministic regardless of +// insertion order. The returned string is the lowercase hex encoding of the +// 256-bit digest. +// +// Both .data and .stringData are included. To match Kubernetes API-server write +// semantics, .stringData entries are merged into a copy of .data (with +// .stringData keys taking precedence) before hashing. This ensures the hash is +// consistent whether called on a desired object (which may use .stringData) or +// a cluster-read object (where the API server has already merged .stringData +// into .data). +// +// A common use case is to annotate a Deployment's pod template with this hash +// so that a change in Secret content triggers a rolling restart: +// +// hash, err := secret.DataHash(s) +// if err != nil { +// return err +// } +// m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { +// e.EnsureAnnotation("checksum/secret", hash) +// return nil +// }) +func DataHash(s corev1.Secret) (string, error) { + // Build the effective data map by merging .stringData into a copy of .data, + // mirroring the Kubernetes API server's write semantics. + data := make(map[string][]byte, len(s.Data)+len(s.StringData)) + for k, v := range s.Data { + data[k] = v + } + for k, v := range s.StringData { + data[k] = []byte(v) + } + + encoded, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("secret %s/%s: failed to marshal data for hashing: %w", + s.Namespace, s.Name, err) + } + + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + +// DesiredHash computes the SHA-256 hash of the Secret as it will be written to +// the cluster, based on the base object and all registered mutations. +// +// The hash covers only operator-controlled data content: the effective Secret +// data after applying the baseline and mutations, with .stringData merged into +// .data (and .stringData keys taking precedence), matching DataHash semantics. +// Fields preserved by flavors from the live cluster state (e.g. +// PreserveExternalEntries) are intentionally excluded — only changes to +// operator-owned content will change the hash. +// +// This enables a single-pass checksum pattern: compute the hash before +// reconciliation and pass it directly to the deployment resource factory, +// avoiding the need for a second reconcile cycle. +// +// secretResource, err := secret.NewBuilder(base).WithMutation(...).Build() +// hash, err := secretResource.DesiredHash() +// deployResource, err := deployment.NewBuilder(base). +// WithMutation(ChecksumMutation(version, hash)). +// Build() +func (r *Resource) DesiredHash() (string, error) { + obj, err := r.base.PreviewObject() + if err != nil { + return "", fmt.Errorf("secret %s: failed to compute desired hash: %w", r.Identity(), err) + } + + return DataHash(*obj) +} diff --git a/pkg/primitives/secret/hash_test.go b/pkg/primitives/secret/hash_test.go new file mode 100644 index 00000000..6c06b514 --- /dev/null +++ b/pkg/primitives/secret/hash_test.go @@ -0,0 +1,266 @@ +package secret + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newHashTestSecret(data map[string][]byte) corev1.Secret { + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: data, + } +} + +// --- Determinism --- + +func TestDataHash_Deterministic(t *testing.T) { + s := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + h1, err := DataHash(s) + require.NoError(t, err) + h2, err := DataHash(s) + require.NoError(t, err) + assert.Equal(t, h1, h2) +} + +func TestDataHash_InsertionOrderIndependent(t *testing.T) { + // Maps in Go have non-deterministic iteration order; JSON encoding sorts keys. + s1 := newHashTestSecret(map[string][]byte{"a": []byte("1"), "b": []byte("2")}) + s2 := newHashTestSecret(map[string][]byte{"b": []byte("2"), "a": []byte("1")}) + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.Equal(t, h1, h2, "hash must be order-independent") +} + +// --- Sensitivity --- + +func TestDataHash_ChangesOnDataChange(t *testing.T) { + s1 := newHashTestSecret(map[string][]byte{"key": []byte("old")}) + s2 := newHashTestSecret(map[string][]byte{"key": []byte("new")}) + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.NotEqual(t, h1, h2) +} + +func TestDataHash_ChangesOnKeyChange(t *testing.T) { + s1 := newHashTestSecret(map[string][]byte{"key-a": []byte("value")}) + s2 := newHashTestSecret(map[string][]byte{"key-b": []byte("value")}) + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.NotEqual(t, h1, h2) +} + +// --- Edge cases --- + +func TestDataHash_NilAndEmptyMapHashIdentically(t *testing.T) { + sNil := newHashTestSecret(nil) + sEmpty := newHashTestSecret(map[string][]byte{}) + + hNil, err := DataHash(sNil) + require.NoError(t, err) + hEmpty, err := DataHash(sEmpty) + require.NoError(t, err) + + assert.Equal(t, hNil, hEmpty, "nil and empty data must produce the same hash") +} + +func TestDataHash_EmptySecret(t *testing.T) { + s := newHashTestSecret(nil) + h, err := DataHash(s) + require.NoError(t, err) + assert.NotEmpty(t, h) +} + +func TestDataHash_MetadataIgnored(t *testing.T) { + s1 := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + s2 := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + s2.Labels = map[string]string{"extra": "label"} + s2.Annotations = map[string]string{"extra": "annotation"} + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.Equal(t, h1, h2, "metadata must not influence the hash") +} + +func TestDataHash_ReturnsHexString(t *testing.T) { + s := newHashTestSecret(map[string][]byte{"k": []byte("v")}) + h, err := DataHash(s) + require.NoError(t, err) + // SHA-256 in hex is always 64 characters. + assert.Len(t, h, 64) + for _, c := range h { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'), + "expected lowercase hex character, got %c", c) + } +} + +// --- StringData merging --- + +func TestDataHash_StringDataIncluded(t *testing.T) { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + StringData: map[string]string{"key": "value"}, + } + hSD, err := DataHash(s) + require.NoError(t, err) + + sData := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + hData, err := DataHash(sData) + require.NoError(t, err) + + assert.Equal(t, hSD, hData, "stringData and equivalent data must produce the same hash") +} + +func TestDataHash_StringDataOverridesData(t *testing.T) { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("from-data")}, + StringData: map[string]string{"key": "from-stringdata"}, + } + h1, err := DataHash(s) + require.NoError(t, err) + + sExpected := newHashTestSecret(map[string][]byte{"key": []byte("from-stringdata")}) + h2, err := DataHash(sExpected) + require.NoError(t, err) + + assert.Equal(t, h1, h2, "stringData must override data for the same key") +} + +// --- DesiredHash --- + +func newHashTestResource(t *testing.T, data map[string][]byte, mutations ...Mutation) *Resource { + t.Helper() + base := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: data, + } + b := NewBuilder(base) + for _, m := range mutations { + b.WithMutation(m) + } + r, err := b.Build() + require.NoError(t, err) + return r +} + +func TestDesiredHash_Deterministic(t *testing.T) { + r := newHashTestResource(t, map[string][]byte{"key": []byte("value")}) + h1, err := r.DesiredHash() + require.NoError(t, err) + h2, err := r.DesiredHash() + require.NoError(t, err) + assert.Equal(t, h1, h2) +} + +func TestDesiredHash_NoSideEffects(t *testing.T) { + // Calling DesiredHash must not modify the resource's internal state. + r := newHashTestResource(t, nil, Mutation{ + Name: "set-key", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { + m.SetData("key", []byte("value")) + return nil + }, + }) + + h1, err := r.DesiredHash() + require.NoError(t, err) + h2, err := r.DesiredHash() + require.NoError(t, err) + assert.Equal(t, h1, h2, "repeated DesiredHash calls must return the same value") + + // Verify the resource still reconciles correctly after hash computation. + s := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}} + require.NoError(t, r.Mutate(s)) + require.NoError(t, r.Mutate(s)) + assert.Equal(t, []byte("value"), s.Data["key"], "mutations must not be applied more than once per reconcile") +} + +func TestDesiredHash_ChangesWhenMutationChangesContent(t *testing.T) { + r1 := newHashTestResource(t, nil, Mutation{ + Name: "set-key", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("v1")); return nil }, + }) + r2 := newHashTestResource(t, nil, Mutation{ + Name: "set-key", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("v2")); return nil }, + }) + + h1, err := r1.DesiredHash() + require.NoError(t, err) + h2, err := r2.DesiredHash() + require.NoError(t, err) + assert.NotEqual(t, h1, h2) +} + +func TestDesiredHash_DisabledMutationDoesNotAffectHash(t *testing.T) { + base := newHashTestResource(t, nil, Mutation{ + Name: "always", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("value")); return nil }, + }) + + withDisabled := newHashTestResource(t, nil, + Mutation{ + Name: "always", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("value")); return nil }, + }, + Mutation{ + Name: "disabled", + Feature: feature.NewResourceFeature("1.0.0", nil).When(false), + Mutate: func(m *Mutator) error { m.SetData("extra", []byte("skipped")); return nil }, + }, + ) + + h1, err := base.DesiredHash() + require.NoError(t, err) + h2, err := withDisabled.DesiredHash() + require.NoError(t, err) + assert.Equal(t, h1, h2, "disabled mutation must not influence the hash") +} + +func TestDesiredHash_MetadataOnlyMutationDoesNotAffectHash(t *testing.T) { + withoutLabel := newHashTestResource(t, map[string][]byte{"key": []byte("value")}) + withLabel := newHashTestResource(t, map[string][]byte{"key": []byte("value")}, Mutation{ + Name: "label", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("extra", "label") + return nil + }) + return nil + }, + }) + + h1, err := withoutLabel.DesiredHash() + require.NoError(t, err) + h2, err := withLabel.DesiredHash() + require.NoError(t, err) + assert.Equal(t, h1, h2, "metadata-only mutations must not influence the hash") +} diff --git a/pkg/primitives/secret/mutator.go b/pkg/primitives/secret/mutator.go new file mode 100644 index 00000000..dd494c17 --- /dev/null +++ b/pkg/primitives/secret/mutator.go @@ -0,0 +1,179 @@ +// Package secret provides a builder and resource for managing Kubernetes Secrets. +package secret + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a secret Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + dataEdits []func(*editors.SecretDataEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes Secret. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Secret in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + secret *corev1.Secret + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Secret. +// BeginFeature must be called before registering any mutations. +func NewMutator(s *corev1.Secret) *Mutator { + return &Mutator{ + secret: s, + } +} + +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Secret's own metadata. +// +// Metadata edits are applied before data edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + if m.active == nil { + panic("BeginFeature must be called before registering mutations") + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditData records a mutation for the Secret's .data and .stringData fields +// via a SecretDataEditor. +// +// The editor provides structured operations (Set, Remove, SetString, +// RemoveString) as well as Raw() and RawStringData() for free-form access. +// Data edits are applied after metadata edits within the same feature, in +// registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditData(edit func(*editors.SecretDataEditor) error) { + if edit == nil { + return + } + if m.active == nil { + panic("BeginFeature must be called before registering mutations") + } + m.active.dataEdits = append(m.active.dataEdits, edit) +} + +// SetData records that key in .data should be set to value. +// +// Convenience wrapper over EditData. +func (m *Mutator) SetData(key string, value []byte) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.Set(key, value) + return nil + }) +} + +// RemoveData records that key should be deleted from .data. +// +// Convenience wrapper over EditData. +func (m *Mutator) RemoveData(key string) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.Remove(key) + return nil + }) +} + +// SetStringData records that key in .stringData should be set to value. +// +// The API server merges .stringData into .data on write; use this when +// working with plaintext values that should not be pre-encoded. +// +// Convenience wrapper over EditData. +func (m *Mutator) SetStringData(key, value string) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.SetString(key, value) + return nil + }) +} + +// RemoveStringData records that key should be deleted from .stringData. +// +// Convenience wrapper over EditData. +func (m *Mutator) RemoveStringData(key string) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.RemoveString(key) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying Secret. +// +// Execution order within each plan: +// +// 1. Metadata edits (in registration order) +// 2. Data edits — EditData, SetData, RemoveData, SetStringData, RemoveStringData +// (in registration order) +// +// Plans are applied sequentially. Later edits observe the Secret as modified +// by all earlier edits. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.secret.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Data edits + if len(plan.dataEdits) > 0 { + editor := editors.NewSecretDataEditor(&m.secret.Data, &m.secret.StringData) + for _, edit := range plan.dataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + // Normalize: merge .stringData into .data and clear .stringData. + // The API server performs this same merge on write and never returns + // .stringData on reads. Doing it here ensures the mutated object matches + // the server-persisted form, preventing spurious Updates from + // controllerutil.CreateOrUpdate on every reconcile. + if m.secret.StringData != nil { + if len(m.secret.StringData) > 0 { + if m.secret.Data == nil { + m.secret.Data = make(map[string][]byte, len(m.secret.StringData)) + } + for k, v := range m.secret.StringData { + m.secret.Data[k] = []byte(v) + } + } + // Always clear StringData so the in-memory Secret matches the + // API server's canonical form, which never includes stringData. + m.secret.StringData = nil + } + + return nil +} diff --git a/pkg/primitives/secret/mutator_test.go b/pkg/primitives/secret/mutator_test.go new file mode 100644 index 00000000..a7db5492 --- /dev/null +++ b/pkg/primitives/secret/mutator_test.go @@ -0,0 +1,250 @@ +package secret + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestSecret(data map[string][]byte) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + Data: data, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", s.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditData --- + +func TestMutator_EditData_RawAccess(t *testing.T) { + s := newTestSecret(map[string][]byte{"existing": []byte("keep")}) + m := NewMutator(s) + m.BeginFeature() + m.EditData(func(e *editors.SecretDataEditor) error { + raw := e.Raw() + raw["new"] = []byte("added") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("keep"), s.Data["existing"]) + assert.Equal(t, []byte("added"), s.Data["new"]) +} + +func TestMutator_EditData_Nil(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.EditData(nil) + assert.NoError(t, m.Apply()) +} + +// --- SetData --- + +func TestMutator_SetData(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.SetData("key", []byte("value")) + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("value"), s.Data["key"]) +} + +func TestMutator_SetData_Overwrites(t *testing.T) { + s := newTestSecret(map[string][]byte{"key": []byte("old")}) + m := NewMutator(s) + m.BeginFeature() + m.SetData("key", []byte("new")) + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("new"), s.Data["key"]) +} + +// --- RemoveData --- + +func TestMutator_RemoveData(t *testing.T) { + s := newTestSecret(map[string][]byte{"key": []byte("value"), "other": []byte("keep")}) + m := NewMutator(s) + m.BeginFeature() + m.RemoveData("key") + require.NoError(t, m.Apply()) + assert.NotContains(t, s.Data, "key") + assert.Equal(t, []byte("keep"), s.Data["other"]) +} + +func TestMutator_RemoveData_NotPresent(t *testing.T) { + s := newTestSecret(map[string][]byte{"other": []byte("keep")}) + m := NewMutator(s) + m.BeginFeature() + m.RemoveData("missing") + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("keep"), s.Data["other"]) +} + +// --- SetStringData --- + +func TestMutator_SetStringData(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.SetStringData("key", "value") + require.NoError(t, m.Apply()) + // After Apply, stringData is normalized into data and cleared. + assert.Equal(t, []byte("value"), s.Data["key"]) + assert.Nil(t, s.StringData) +} + +// --- RemoveStringData --- + +func TestMutator_RemoveStringData(t *testing.T) { + s := newTestSecret(nil) + s.StringData = map[string]string{"key": "value", "other": "keep"} + m := NewMutator(s) + m.BeginFeature() + m.RemoveStringData("key") + require.NoError(t, m.Apply()) + // After Apply, remaining stringData is normalized into data and cleared. + assert.NotContains(t, s.Data, "key") + assert.Equal(t, []byte("keep"), s.Data["other"]) + assert.Nil(t, s.StringData) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Both metadata and data edits are applied regardless of registration order. + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + // Register data edit before metadata edit; both should be applied. + m.SetData("direct", []byte("yes")) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", s.Labels["order"]) + assert.Equal(t, []byte("yes"), s.Data["direct"]) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.SetData("feature1", []byte("on")) + m.BeginFeature() + m.SetData("feature2", []byte("on")) + require.NoError(t, m.Apply()) + + assert.Equal(t, []byte("on"), s.Data["feature1"]) + assert.Equal(t, []byte("on"), s.Data["feature2"]) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.SetData("f0", []byte("val0")) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.SetData("f1", []byte("val1")) + + // The initial plan should have exactly one data edit + assert.Len(t, m.plans[0].dataEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one data edit + assert.Len(t, m.plans[1].dataEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.SetData("key", []byte("value")) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, []byte("value"), s.Data["key"]) +} + +// --- BeginFeature guard --- + +func TestMutator_EditObjectMetadata_PanicsWithoutBeginFeature(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + assert.PanicsWithValue(t, "BeginFeature must be called before registering mutations", func() { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + }) +} + +func TestMutator_EditData_PanicsWithoutBeginFeature(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + assert.PanicsWithValue(t, "BeginFeature must be called before registering mutations", func() { + m.EditData(func(e *editors.SecretDataEditor) error { + e.Set("key", []byte("value")) + return nil + }) + }) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/secret/resource.go b/pkg/primitives/secret/resource.go new file mode 100644 index 00000000..41614cc8 --- /dev/null +++ b/pkg/primitives/secret/resource.go @@ -0,0 +1,54 @@ +package secret + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes Secret within +// a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.DataExtractable: for exporting values after successful reconciliation. +// +// Secret resources are static: they do not model convergence health, grace periods, +// or suspension. Use a workload or task primitive for resources that require those concepts. +type Resource struct { + base *generic.StaticResource[*corev1.Secret, *Mutator] +} + +// Identity returns a unique identifier for the Secret in the format +// "v1/Secret//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes Secret object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes Secret into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled Secret. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the Secret. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/secret/resource_test.go b/pkg/primitives/secret/resource_test.go new file mode 100644 index 00000000..05aac157 --- /dev/null +++ b/pkg/primitives/secret/resource_test.go @@ -0,0 +1,142 @@ +package secret + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidSecret() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{"key": []byte("value")}, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidSecret()).Build() + require.NoError(t, err) + assert.Equal(t, "v1/Secret/test-ns/test-secret", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + s := newValidSecret() + res, err := NewBuilder(s).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*corev1.Secret) + require.True(t, ok) + assert.Equal(t, s.Name, got.Name) + assert.Equal(t, s.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-secret", s.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidSecret() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.Secret) + assert.Equal(t, []byte("value"), got.Data["key"]) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidSecret() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-entry", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetData("from-mutation", []byte("yes")) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.Secret) + assert.Equal(t, []byte("value"), got.Data["key"]) + assert.Equal(t, []byte("yes"), got.Data["from-mutation"]) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + // When two features write the same key, the last feature wins (deterministic ordering). + desired := newValidSecret() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetData("order", []byte("a")) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetData("order", []byte("b")) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.Secret) + assert.Equal(t, []byte("b"), got.Data["order"]) +} + +func TestResource_ExtractData(t *testing.T) { + s := newValidSecret() + + var extracted []byte + res, err := NewBuilder(s). + WithDataExtractor(func(c corev1.Secret) error { + extracted = c.Data["key"] + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, []byte("value"), extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidSecret()). + WithDataExtractor(func(_ corev1.Secret) error { + return errors.New("extract error") + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.Error(t, err) + assert.Contains(t, err.Error(), "extract error") +}