-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement serviceaccount primitive #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
f42fe26
Add serviceaccount primitive package
sourcehawk 36fa790
Add serviceaccount primitive documentation
sourcehawk dececa1
Add serviceaccount primitive example
sourcehawk 64d398f
Fix gofmt formatting in serviceaccount example
sourcehawk 8c2c6db
Address Copilot review comments on serviceaccount example
sourcehawk 32098f4
Preserve server-managed metadata in default field applicator
sourcehawk 95ed3b3
fix duplicate calls to beginFeature in serviceaccount mutator constru…
sourcehawk af10313
export BeginFeature and use nil Feature for unconditional mutations
sourcehawk 9328bb2
fix lint error and update serviceaccount docs after rebase
sourcehawk 8f5f14d
Merge remote-tracking branch 'origin/main' into feature/serviceaccoun…
sourcehawk 236bfba
format serviceaccount markdown files with prettier
sourcehawk ea07303
add serviceaccount to built-in primitives table in docs
sourcehawk 5925f27
format docs/primitives.md with prettier to fix lint
sourcehawk c8ec914
address copilot review: preserve SA secrets, snapshot automount bool
sourcehawk 4d18ac9
address copilot review: document .secrets ownership, expand test cove…
sourcehawk 228fc59
fix doc comment: ServiceAccount has no Spec, .Secrets is top-level
sourcehawk b23afdc
Merge remote-tracking branch 'origin/main' into feature/serviceaccoun…
sourcehawk 0c05584
do not initialize an empty plan on serviceaccount mutator construction
sourcehawk c14f026
Merge remote-tracking branch 'origin/main' into feature/serviceaccoun…
sourcehawk cd27004
remove field applicators and flavors from serviceaccount primitive
sourcehawk 2ee1b8e
remove references to deleted field applicators in serviceaccount example
sourcehawk ecb6761
address copilot review: remove stale flavor references, add SA exampl…
sourcehawk 1f3469b
add ensureActive guard to serviceaccount mutator methods
sourcehawk 87eceaa
replace panic with lazy BeginFeature in serviceaccount ensureActive g…
sourcehawk 20e3236
Merge branch 'main' into feature/serviceaccount-primitive
sourcehawk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| }, | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
make run-examplescurrently runs a hard-coded set of examples and does not includeexamples/serviceaccount-primitive(verified in Makefile). If the intent is to keeprun-examplesvalidating all examples, this new example should be added there so it is exercised in the same way as the existing deployment/configmap examples.