diff --git a/Makefile b/Makefile index 1575573f..fc9838f9 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/serviceaccount-primitive/. go run ./examples/secret-primitive/. go run ./examples/statefulset-primitive/. go run ./examples/replicaset-primitive/. diff --git a/docs/primitives.md b/docs/primitives.md index 64df927b..5a444104 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -167,6 +167,7 @@ have been applied. This means a single mutation can safely add a container and t | `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) | +| `pkg/primitives/serviceaccount` | Static | [serviceaccount.md](primitives/serviceaccount.md) | | `pkg/primitives/service` | Integration | [service.md](primitives/service.md) | | `pkg/primitives/pv` | Integration | [pv.md](primitives/pv.md) | | `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) | diff --git a/docs/primitives/serviceaccount.md b/docs/primitives/serviceaccount.md new file mode 100644 index 00000000..af7688a8 --- /dev/null +++ b/docs/primitives/serviceaccount.md @@ -0,0 +1,194 @@ +# ServiceAccount Primitive + +The `serviceaccount` primitive is the framework's built-in static abstraction for managing Kubernetes `ServiceAccount` +resources. It integrates with the component lifecycle and provides a structured mutation API for managing image pull +secrets, the automount token flag, and object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | --------------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Direct mutator methods for `.imagePullSecrets` and `.automountServiceAccountToken`, plus metadata editors | +| **Data extraction** | Reads generated or updated values back from the reconciled ServiceAccount after each sync cycle | + +## Building a ServiceAccount Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/serviceaccount" + +base := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-sa", + Namespace: owner.Namespace, + }, +} + +resource, err := serviceaccount.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `ServiceAccount` beyond its baseline. Each mutation is a named +function that receives a `*Mutator` and records edit intent through direct methods. + +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) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *serviceaccount.Mutator) error { + m.EnsureImagePullSecret("my-registry") + 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 PrivateRegistryMutation(version string, usePrivateRegistry bool) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "private-registry", + Feature: feature.NewResourceFeature(version, nil).When(usePrivateRegistry), + Mutate: func(m *serviceaccount.Mutator) error { + m.EnsureImagePullSecret("private-registry-creds") + return nil + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyTokenMutation(version string) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "legacy-token", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *serviceaccount.Mutator) error { + v := true + m.SetAutomountServiceAccountToken(&v) + 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 `ServiceAccount` | +| 2 | Image pull secret edits | `.imagePullSecrets` — EnsureImagePullSecret, RemoveImagePullSecret | +| 3 | Automount edits | `.automountServiceAccountToken` — SetAutomountServiceAccountToken | + +Within each category, edits are applied in their registration order. Later features observe the ServiceAccount as +modified by all previous features. + +## Mutator Methods + +### EnsureImagePullSecret + +Adds a named image pull secret to `.imagePullSecrets` if not already present. Idempotent — calling it with an +already-present name is a no-op. + +```go +m.EnsureImagePullSecret("my-registry-creds") +``` + +### RemoveImagePullSecret + +Removes a named image pull secret from `.imagePullSecrets`. It is a no-op if the secret is not present. + +```go +m.RemoveImagePullSecret("old-registry-creds") +``` + +### SetAutomountServiceAccountToken + +Sets `.automountServiceAccountToken` to the provided value. Pass `nil` to unset the field. + +```go +v := false +m.SetAutomountServiceAccountToken(&v) +``` + +### EditObjectMetadata + +Modifies labels and annotations via `editors.ObjectMetaEditor`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("managed-by", "my-operator") + return nil +}) +``` + +## Full Example: Feature-Composed ServiceAccount + +```go +func BaseImagePullSecretMutation(version string) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "base-pull-secret", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *serviceaccount.Mutator) error { + m.EnsureImagePullSecret("default-registry") + return nil + }, + } +} + +func DisableAutomountMutation(version string, disableAutomount bool) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "disable-automount", + Feature: feature.NewResourceFeature(version, nil).When(disableAutomount), + Mutate: func(m *serviceaccount.Mutator) error { + v := false + m.SetAutomountServiceAccountToken(&v) + return nil + }, + } +} + +resource, err := serviceaccount.NewBuilder(base). + WithMutation(BaseImagePullSecretMutation(owner.Spec.Version)). + WithMutation(DisableAutomountMutation(owner.Spec.Version, owner.Spec.DisableAutomount)). + Build() +``` + +When `DisableAutomount` is true, `.automountServiceAccountToken` is set to `false`. When the condition is not met, the +field is left at its baseline value. Neither mutation needs to know about the other. + +## 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. + +**Use `EnsureImagePullSecret` for idempotent secret registration.** Multiple features can independently ensure their +required pull secrets without conflicting with each other. + +**Register mutations in dependency order.** If mutation B relies on a secret added by mutation A, register A first. diff --git a/examples/serviceaccount-primitive/README.md b/examples/serviceaccount-primitive/README.md new file mode 100644 index 00000000..6de01570 --- /dev/null +++ b/examples/serviceaccount-primitive/README.md @@ -0,0 +1,33 @@ +# ServiceAccount Primitive Example + +This example demonstrates the usage of the `serviceaccount` primitive within the operator component framework. It shows +how to manage a Kubernetes ServiceAccount as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a ServiceAccount with basic metadata. +- **Feature Mutations**: Composing image pull secrets and automount settings from independent, feature-gated mutations. +- **Metadata Mutations**: Setting version labels on the ServiceAccount via `EditObjectMetadata`. +- **Data Extraction**: Harvesting ServiceAccount fields 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`: version labelling, image pull secrets, private registry, and automount control. +- `resources/`: Contains the central `NewServiceAccountResource` factory that assembles all features using + `serviceaccount.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/serviceaccount-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 ServiceAccount's image pull secrets and automount settings after + each cycle. +4. Print the resulting status conditions. diff --git a/examples/serviceaccount-primitive/app/controller.go b/examples/serviceaccount-primitive/app/controller.go new file mode 100644 index 00000000..ed3980d4 --- /dev/null +++ b/examples/serviceaccount-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the serviceaccount 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 + + // NewServiceAccountResource is a factory function to create the serviceaccount resource. + // This allows us to inject the resource construction logic. + NewServiceAccountResource 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 serviceaccount resource for this owner. + saResource, err := r.NewServiceAccountResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the serviceaccount. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(saResource, 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/serviceaccount-primitive/features/mutations.go b/examples/serviceaccount-primitive/features/mutations.go new file mode 100644 index 00000000..f8148b38 --- /dev/null +++ b/examples/serviceaccount-primitive/features/mutations.go @@ -0,0 +1,64 @@ +// Package features provides sample mutations for the serviceaccount 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/serviceaccount" +) + +// VersionLabelMutation sets the app.kubernetes.io/version label on the ServiceAccount. +// It is always enabled. +func VersionLabelMutation(version string) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "version-label", + Feature: nil, + Mutate: func(m *serviceaccount.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// ImagePullSecretMutation ensures the default registry pull secret is attached +// to the ServiceAccount. It is always enabled. +func ImagePullSecretMutation(_ string) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "image-pull-secret", + Feature: nil, + Mutate: func(m *serviceaccount.Mutator) error { + m.EnsureImagePullSecret("default-registry-creds") + return nil + }, + } +} + +// PrivateRegistryMutation adds a private registry pull secret to the ServiceAccount. +// It is enabled when usePrivateRegistry is true. +func PrivateRegistryMutation(version string, usePrivateRegistry bool) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "private-registry", + Feature: feature.NewResourceFeature(version, nil).When(usePrivateRegistry), + Mutate: func(m *serviceaccount.Mutator) error { + m.EnsureImagePullSecret("private-registry-creds") + return nil + }, + } +} + +// DisableAutomountMutation disables automatic mounting of the service account token. +// It is enabled when disableAutomount is true. +func DisableAutomountMutation(version string, disableAutomount bool) serviceaccount.Mutation { + return serviceaccount.Mutation{ + Name: "disable-automount", + Feature: feature.NewResourceFeature(version, nil).When(disableAutomount), + Mutate: func(m *serviceaccount.Mutator) error { + v := false + m.SetAutomountServiceAccountToken(&v) + return nil + }, + } +} diff --git a/examples/serviceaccount-primitive/main.go b/examples/serviceaccount-primitive/main.go new file mode 100644 index 00000000..98f28d9a --- /dev/null +++ b/examples/serviceaccount-primitive/main.go @@ -0,0 +1,115 @@ +// Package main is the entry point for the serviceaccount 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/serviceaccount-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/serviceaccount-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, + }, + NewServiceAccountResource: resources.NewServiceAccountResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate how + // feature-gated mutations compose image pull secrets and automount settings. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, // adds private registry secret + EnableMetrics: true, // disables automount + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, // Remove private registry + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: false, // Re-enable automount (default) + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, EnableTracing=%v, EnableMetrics=%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/serviceaccount-primitive/resources/serviceaccount.go b/examples/serviceaccount-primitive/resources/serviceaccount.go new file mode 100644 index 00000000..3cfa6a5f --- /dev/null +++ b/examples/serviceaccount-primitive/resources/serviceaccount.go @@ -0,0 +1,62 @@ +// Package resources provides resource implementations for the serviceaccount primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/serviceaccount-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/serviceaccount" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewServiceAccountResource constructs a serviceaccount primitive resource with all the features. +func NewServiceAccountResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base ServiceAccount object. + base := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-sa", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + } + + // 2. Initialize the serviceaccount builder. + builder := serviceaccount.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.ImagePullSecretMutation(owner.Spec.Version)) + builder.WithMutation(features.PrivateRegistryMutation(owner.Spec.Version, owner.Spec.EnableTracing)) + // In this example, we reuse EnableMetrics to drive the disable-automount feature + // purely to demonstrate the mutation. A real application would typically use a + // dedicated DisableAutomount boolean in its spec instead. + disableAutomount := owner.Spec.EnableMetrics + builder.WithMutation(features.DisableAutomountMutation(owner.Spec.Version, disableAutomount)) + + // 4. Extract data from the reconciled ServiceAccount. + builder.WithDataExtractor(func(sa corev1.ServiceAccount) error { + fmt.Printf("Reconciled ServiceAccount: %s\n", sa.Name) + fmt.Printf(" ImagePullSecrets: ") + for i, ref := range sa.ImagePullSecrets { + if i > 0 { + fmt.Print(", ") + } + fmt.Print(ref.Name) + } + fmt.Println() + if sa.AutomountServiceAccountToken != nil { + fmt.Printf(" AutomountServiceAccountToken: %v\n", *sa.AutomountServiceAccountToken) + } else { + fmt.Println(" AutomountServiceAccountToken: ") + } + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/primitives/serviceaccount/builder.go b/pkg/primitives/serviceaccount/builder.go new file mode 100644 index 00000000..937b40be --- /dev/null +++ b/pkg/primitives/serviceaccount/builder.go @@ -0,0 +1,79 @@ +package serviceaccount + +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 ServiceAccount 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.ServiceAccount, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided ServiceAccount object. +// +// The ServiceAccount 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 ServiceAccount must have both Name and Namespace set, which is validated +// during the Build() call. +func NewBuilder(sa *corev1.ServiceAccount) *Builder { + identityFunc := func(s *corev1.ServiceAccount) string { + return fmt.Sprintf("v1/ServiceAccount/%s/%s", s.Namespace, s.Name) + } + + return &Builder{ + base: generic.NewStaticBuilder[*corev1.ServiceAccount, *Mutator]( + sa, + identityFunc, + NewMutator, + ), + } +} + +// WithMutation registers a mutation for the ServiceAccount. +// +// 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 ServiceAccount after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled ServiceAccount. 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.ServiceAccount) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(sa *corev1.ServiceAccount) error { + return extractor(*sa) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No ServiceAccount object was provided. +// - The ServiceAccount 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/serviceaccount/builder_test.go b/pkg/primitives/serviceaccount/builder_test.go new file mode 100644 index 00000000..5d295960 --- /dev/null +++ b/pkg/primitives/serviceaccount/builder_test.go @@ -0,0 +1,122 @@ +package serviceaccount + +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 + sa *corev1.ServiceAccount + expectedErr string + }{ + { + name: "nil serviceaccount", + sa: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + sa: &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + sa: &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "test-sa"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid serviceaccount", + sa: &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "test-sa", Namespace: "test-ns"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.sa).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/ServiceAccount/test-ns/test-sa", res.Identity()) + } + }) + } +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "test-sa", Namespace: "test-ns"}, + } + res, err := NewBuilder(sa). + 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() + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "test-sa", Namespace: "test-ns"}, + } + called := false + extractor := func(_ corev1.ServiceAccount) error { + called = true + return nil + } + res, err := NewBuilder(sa). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&corev1.ServiceAccount{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "test-sa", Namespace: "test-ns"}, + } + res, err := NewBuilder(sa). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "test-sa", Namespace: "test-ns"}, + } + res, err := NewBuilder(sa). + WithDataExtractor(func(_ corev1.ServiceAccount) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&corev1.ServiceAccount{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/serviceaccount/mutator.go b/pkg/primitives/serviceaccount/mutator.go new file mode 100644 index 00000000..a062e271 --- /dev/null +++ b/pkg/primitives/serviceaccount/mutator.go @@ -0,0 +1,153 @@ +// Package serviceaccount provides a builder and resource for managing Kubernetes ServiceAccounts. +package serviceaccount + +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 serviceaccount Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + imagePullSecretEdits []func(*corev1.ServiceAccount) + automountEdits []func(*corev1.ServiceAccount) +} + +// Mutator is a high-level helper for modifying a Kubernetes ServiceAccount. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the ServiceAccount 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 { + sa *corev1.ServiceAccount + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given ServiceAccount. +// BeginFeature must be called before registering any mutations. +func NewMutator(sa *corev1.ServiceAccount) *Mutator { + return &Mutator{ + sa: sa, + } +} + +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +// +// This method satisfies the generic.FeatureMutator interface, allowing the +// generic builder to maintain per-feature ordering semantics for external mutators. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +func (m *Mutator) ensureActive() { + if m.active == nil { + m.BeginFeature() + } +} + +// EditObjectMetadata records a mutation for the ServiceAccount's own metadata. +// +// Metadata edits are applied before image pull secret and automount edits within +// the same feature. A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.ensureActive() + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EnsureImagePullSecret records that the named image pull secret should be present +// in .imagePullSecrets. If a secret with the same name already exists, it is a no-op. +func (m *Mutator) EnsureImagePullSecret(name string) { + m.ensureActive() + m.active.imagePullSecretEdits = append(m.active.imagePullSecretEdits, func(sa *corev1.ServiceAccount) { + for _, ref := range sa.ImagePullSecrets { + if ref.Name == name { + return + } + } + sa.ImagePullSecrets = append(sa.ImagePullSecrets, corev1.LocalObjectReference{Name: name}) + }) +} + +// RemoveImagePullSecret records that the named image pull secret should be removed +// from .imagePullSecrets. It is a no-op if the secret is not present. +func (m *Mutator) RemoveImagePullSecret(name string) { + m.ensureActive() + m.active.imagePullSecretEdits = append(m.active.imagePullSecretEdits, func(sa *corev1.ServiceAccount) { + filtered := sa.ImagePullSecrets[:0] + for _, ref := range sa.ImagePullSecrets { + if ref.Name != name { + filtered = append(filtered, ref) + } + } + sa.ImagePullSecrets = filtered + }) +} + +// SetAutomountServiceAccountToken records that .automountServiceAccountToken should +// be set to the provided value. Pass nil to unset the field. +// +// The pointed-to value is snapshotted at registration time so that Apply() +// is deterministic regardless of later caller-side mutations. +func (m *Mutator) SetAutomountServiceAccountToken(v *bool) { + var snapshot *bool + if v != nil { + val := *v + snapshot = &val + } + + m.ensureActive() + m.active.automountEdits = append(m.active.automountEdits, func(sa *corev1.ServiceAccount) { + sa.AutomountServiceAccountToken = snapshot + }) +} + +// Apply executes all recorded mutation intents on the underlying ServiceAccount. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Image pull secret edits — EnsureImagePullSecret, RemoveImagePullSecret (in registration order within each feature) +// 3. Automount edits — SetAutomountServiceAccountToken (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the ServiceAccount as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.sa.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Image pull secret edits + for _, edit := range plan.imagePullSecretEdits { + edit(m.sa) + } + + // 3. Automount edits + for _, edit := range plan.automountEdits { + edit(m.sa) + } + } + + return nil +} diff --git a/pkg/primitives/serviceaccount/mutator_test.go b/pkg/primitives/serviceaccount/mutator_test.go new file mode 100644 index 00000000..13eb50bf --- /dev/null +++ b/pkg/primitives/serviceaccount/mutator_test.go @@ -0,0 +1,256 @@ +package serviceaccount + +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 newTestSA() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "default", + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", sa.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EnsureImagePullSecret --- + +func TestMutator_EnsureImagePullSecret(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.EnsureImagePullSecret("my-registry") + require.NoError(t, m.Apply()) + require.Len(t, sa.ImagePullSecrets, 1) + assert.Equal(t, "my-registry", sa.ImagePullSecrets[0].Name) +} + +func TestMutator_EnsureImagePullSecret_Idempotent(t *testing.T) { + sa := newTestSA() + sa.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "my-registry"}} + m := NewMutator(sa) + m.BeginFeature() + m.EnsureImagePullSecret("my-registry") + require.NoError(t, m.Apply()) + assert.Len(t, sa.ImagePullSecrets, 1) +} + +func TestMutator_EnsureImagePullSecret_Multiple(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.EnsureImagePullSecret("registry-a") + m.EnsureImagePullSecret("registry-b") + require.NoError(t, m.Apply()) + require.Len(t, sa.ImagePullSecrets, 2) + assert.Equal(t, "registry-a", sa.ImagePullSecrets[0].Name) + assert.Equal(t, "registry-b", sa.ImagePullSecrets[1].Name) +} + +// --- RemoveImagePullSecret --- + +func TestMutator_RemoveImagePullSecret(t *testing.T) { + sa := newTestSA() + sa.ImagePullSecrets = []corev1.LocalObjectReference{ + {Name: "keep"}, + {Name: "remove"}, + } + m := NewMutator(sa) + m.BeginFeature() + m.RemoveImagePullSecret("remove") + require.NoError(t, m.Apply()) + require.Len(t, sa.ImagePullSecrets, 1) + assert.Equal(t, "keep", sa.ImagePullSecrets[0].Name) +} + +func TestMutator_RemoveImagePullSecret_NotPresent(t *testing.T) { + sa := newTestSA() + sa.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "keep"}} + m := NewMutator(sa) + m.BeginFeature() + m.RemoveImagePullSecret("missing") + require.NoError(t, m.Apply()) + assert.Len(t, sa.ImagePullSecrets, 1) +} + +func TestMutator_RemoveImagePullSecret_Empty(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.RemoveImagePullSecret("missing") + require.NoError(t, m.Apply()) + assert.Empty(t, sa.ImagePullSecrets) +} + +// --- SetAutomountServiceAccountToken --- + +func TestMutator_SetAutomountServiceAccountToken(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + v := true + m.SetAutomountServiceAccountToken(&v) + require.NoError(t, m.Apply()) + require.NotNil(t, sa.AutomountServiceAccountToken) + assert.True(t, *sa.AutomountServiceAccountToken) +} + +func TestMutator_SetAutomountServiceAccountToken_False(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + v := false + m.SetAutomountServiceAccountToken(&v) + require.NoError(t, m.Apply()) + require.NotNil(t, sa.AutomountServiceAccountToken) + assert.False(t, *sa.AutomountServiceAccountToken) +} + +func TestMutator_SetAutomountServiceAccountToken_Nil(t *testing.T) { + v := true + sa := newTestSA() + sa.AutomountServiceAccountToken = &v + m := NewMutator(sa) + m.BeginFeature() + m.SetAutomountServiceAccountToken(nil) + require.NoError(t, m.Apply()) + assert.Nil(t, sa.AutomountServiceAccountToken) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata → image pull secrets → automount. + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + // Register in reverse logical order to confirm Apply() enforces category ordering. + v := false + m.SetAutomountServiceAccountToken(&v) + m.EnsureImagePullSecret("my-registry") + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", sa.Labels["order"]) + require.Len(t, sa.ImagePullSecrets, 1) + assert.Equal(t, "my-registry", sa.ImagePullSecrets[0].Name) + require.NotNil(t, sa.AutomountServiceAccountToken) + assert.False(t, *sa.AutomountServiceAccountToken) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.EnsureImagePullSecret("feature1-registry") + m.BeginFeature() + m.EnsureImagePullSecret("feature2-registry") + require.NoError(t, m.Apply()) + + require.Len(t, sa.ImagePullSecrets, 2) + assert.Equal(t, "feature1-registry", sa.ImagePullSecrets[0].Name) + assert.Equal(t, "feature2-registry", sa.ImagePullSecrets[1].Name) +} + +func TestMutator_MultipleFeatures_LaterObservesPrior(t *testing.T) { + // Feature 2 removes a secret added by feature 1. + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.EnsureImagePullSecret("temp-registry") + m.BeginFeature() + m.RemoveImagePullSecret("temp-registry") + require.NoError(t, m.Apply()) + + assert.Empty(t, sa.ImagePullSecrets) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + + 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) { + sa := newTestSA() + m := NewMutator(sa) + + 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) { + sa := newTestSA() + m := NewMutator(sa) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EnsureImagePullSecret("f0-registry") + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EnsureImagePullSecret("f1-registry") + + // The initial plan should have exactly one image pull secret edit + assert.Len(t, m.plans[0].imagePullSecretEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one image pull secret edit + assert.Len(t, m.plans[1].imagePullSecretEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + sa := newTestSA() + m := NewMutator(sa) + m.BeginFeature() + m.EnsureImagePullSecret("my-registry") + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + require.Len(t, sa.ImagePullSecrets, 1) + assert.Equal(t, "my-registry", sa.ImagePullSecrets[0].Name) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/serviceaccount/resource.go b/pkg/primitives/serviceaccount/resource.go new file mode 100644 index 00000000..0245b6e3 --- /dev/null +++ b/pkg/primitives/serviceaccount/resource.go @@ -0,0 +1,54 @@ +package serviceaccount + +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 ServiceAccount 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. +// +// ServiceAccount 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.ServiceAccount, *Mutator] +} + +// Identity returns a unique identifier for the ServiceAccount in the format +// "v1/ServiceAccount//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes ServiceAccount 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 ServiceAccount 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 ServiceAccount. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the ServiceAccount. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/serviceaccount/resource_test.go b/pkg/primitives/serviceaccount/resource_test.go new file mode 100644 index 00000000..960118a2 --- /dev/null +++ b/pkg/primitives/serviceaccount/resource_test.go @@ -0,0 +1,129 @@ +package serviceaccount + +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 newValidSA() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "test-ns", + Labels: map[string]string{"app": "test"}, + }, + } +} + +// --- Resource.Identity tests --- + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidSA()).Build() + require.NoError(t, err) + + assert.Equal(t, "v1/ServiceAccount/test-ns/test-sa", res.Identity()) +} + +// --- Resource.Object tests --- + +func TestResource_Object_ReturnsDeepCopy(t *testing.T) { + sa := newValidSA() + res, err := NewBuilder(sa).Build() + require.NoError(t, err) + + got, err := res.Object() + require.NoError(t, err) + + casted, ok := got.(*corev1.ServiceAccount) + require.True(t, ok) + assert.Equal(t, "test-sa", casted.Name) + + // Mutating the returned copy must not affect the original + casted.Name = "changed" + assert.Equal(t, "test-sa", sa.Name) + + // A second call should also be independent + got2, err := res.Object() + require.NoError(t, err) + assert.Equal(t, "test-sa", got2.GetName()) +} + +// --- Resource.Mutate tests --- + +func TestResource_Mutate_AppliesDesiredState(t *testing.T) { + desired := newValidSA() + desired.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "registry"}} + + 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.ServiceAccount) + assert.Equal(t, "test", got.Labels["app"]) + require.Len(t, got.ImagePullSecrets, 1) + assert.Equal(t, "registry", got.ImagePullSecrets[0].Name) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidSA() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-pull-secret", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { + m.EnsureImagePullSecret("my-registry") + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.ServiceAccount) + require.Len(t, got.ImagePullSecrets, 1) + assert.Equal(t, "my-registry", got.ImagePullSecrets[0].Name) +} + +// --- Resource.ExtractData tests --- + +func TestResource_ExtractData(t *testing.T) { + sa := newValidSA() + sa.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "reg"}} + + var extractedName string + res, err := NewBuilder(sa). + WithDataExtractor(func(s corev1.ServiceAccount) error { + extractedName = s.ImagePullSecrets[0].Name + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "reg", extractedName) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidSA()). + WithDataExtractor(func(_ corev1.ServiceAccount) 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") +}