Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a9e2eb4
Add SecretDataEditor for typed Secret .data/.stringData mutations
sourcehawk Mar 22, 2026
2fd4a0c
Add Secret primitive package with builder, resource, mutator, flavors…
sourcehawk Mar 22, 2026
560b71f
Add Secret primitive documentation
sourcehawk Mar 22, 2026
fa87f99
Add Secret primitive example with feature-gated mutations
sourcehawk Mar 22, 2026
469c464
Address Copilot review: StringData handling in hash, flavors, docs, a…
sourcehawk Mar 22, 2026
74bbea3
Avoid logging secret values in example data extractor
sourcehawk Mar 22, 2026
c468b78
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
7d932b9
Add secret primitive to primitives index documentation
sourcehawk Mar 22, 2026
5f0acc1
align secret docs with preserve status conventions
sourcehawk Mar 23, 2026
20dc53f
fix secret mutator constructor to not call beginFeature
sourcehawk Mar 23, 2026
6fffea1
narrow secret docs and comments to not overstate cross-feature orderi…
sourcehawk Mar 23, 2026
c856ea6
export BeginFeature to satisfy FeatureMutator interface from main
sourcehawk Mar 23, 2026
ea94a18
Merge remote-tracking branch 'origin/main' into feature/secret-primitive
sourcehawk Mar 24, 2026
3262475
format markdown files with prettier
sourcehawk Mar 24, 2026
9ef877f
fix doc/comment inaccuracies about stringData handling in hash functions
sourcehawk Mar 24, 2026
aac72df
format markdown files with prettier
sourcehawk Mar 24, 2026
f9c1aca
normalize stringData into data after Apply to prevent spurious updates
sourcehawk Mar 24, 2026
93233d5
Merge remote-tracking branch 'origin/main' into feature/secret-primitive
sourcehawk Mar 24, 2026
21b8a43
align secret Mutator construction with configmap/deployment pattern
sourcehawk Mar 24, 2026
ceebce9
Merge remote-tracking branch 'origin/main' into feature/secret-primitive
sourcehawk Mar 25, 2026
ba65357
remove field applicators and flavors from secret primitive
sourcehawk Mar 25, 2026
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
16 changes: 9 additions & 7 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,14 @@ 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 |
| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access |
| `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 |
| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access |
| `SecretDataEditor` | `.data` and `.stringData` — set/remove bytes, `SetString`/`RemoveString`, `Raw()`/`RawStringData()` |
| `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.
Expand All @@ -145,6 +146,7 @@ have been applied. This means a single mutation can safely add a container and t
| --------------------------- | -------- | ----------------------------------------- |
| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) |
| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) |
| `pkg/primitives/secret` | Static | [secret.md](primitives/secret.md) |

## Usage Examples

Expand Down
296 changes: 296 additions & 0 deletions docs/primitives/secret.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions examples/secret-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 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`.
- **Field Flavors**: Preserving `.data` entries managed by external controllers using `PreserveExternalEntries`.
- **Data Extraction**: Harvesting Secret entries after each reconcile cycle.
Comment on lines +6 to +10
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This README claims the example demonstrates field flavors via PreserveExternalEntries, but the PR does not implement any FieldApplicationFlavor/PreserveExternalEntries API in pkg/primitives/secret (and the example code currently won’t compile because of that). Update the README to match the implemented API, or add the missing flavor support in the secret primitive.

Copilot uses AI. Check for mistakes.

## 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.
- `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries.
- `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
```
Comment on lines +22 to +26
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repo’s make run-examples target is intended to run all examples, but it currently doesn’t include examples/secret-primitive (Makefile run-examples only runs deployment/configmap/custom-resource-implementation). Consider updating Makefile to include this new example so it’s exercised in the same way as the others.

Copilot uses AI. Check for mistakes.

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.
Loading
Loading