-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement role primitive #30
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
Changes from all commits
6204138
1b55ff9
42ff3ea
49a5c22
87d7705
373c92c
33c2e67
eb1bcf2
5b1e402
e7a6ce9
0768240
8717a99
3991c1b
3e31e00
a280ace
88b350a
5ebcb23
8104f94
2126fc8
a932e18
543f2c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| *.dll | ||
| *.so | ||
| *.dylib | ||
| role-primitive | ||
|
|
||
| # Test binary, built with `go test -c` | ||
| *.test | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,270 @@ | ||
| # Role Primitive | ||
|
|
||
| The `role` primitive is the framework's built-in static abstraction for managing Kubernetes `Role` resources. It | ||
| integrates with the component lifecycle and provides a structured mutation API for managing RBAC policy rules 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 `.rules` and object metadata, with a raw escape hatch for free-form access | | ||
| | **Data extraction** | Reads generated or updated values back from the reconciled Role after each sync cycle | | ||
|
|
||
|
Comment on lines
+9
to
+14
|
||
| ## Building a Role Primitive | ||
|
|
||
| ```go | ||
| import "github.com/sourcehawk/operator-component-framework/pkg/primitives/role" | ||
|
|
||
sourcehawk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| base := &rbacv1.Role{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: "app-role", | ||
| Namespace: owner.Namespace, | ||
| }, | ||
| Rules: []rbacv1.PolicyRule{ | ||
| { | ||
| APIGroups: []string{""}, | ||
| Resources: []string{"pods"}, | ||
| Verbs: []string{"get", "list", "watch"}, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| resource, err := role.NewBuilder(base). | ||
| WithMutation(MyFeatureMutation(owner.Spec.Version)). | ||
| Build() | ||
| ``` | ||
|
|
||
| ## Mutations | ||
|
|
||
| Mutations are the primary mechanism for modifying a `Role` 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) role.Mutation { | ||
| return role.Mutation{ | ||
| Name: "my-feature", | ||
| Feature: feature.NewResourceFeature(version, nil), // always enabled | ||
| Mutate: func(m *role.Mutator) error { | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.AddRule(rbacv1.PolicyRule{ | ||
| APIGroups: []string{""}, | ||
| Resources: []string{"configmaps"}, | ||
| Verbs: []string{"get"}, | ||
| }) | ||
| return nil | ||
| }) | ||
| 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 SecretAccessMutation(version string, enabled bool) role.Mutation { | ||
| return role.Mutation{ | ||
| Name: "secret-access", | ||
| Feature: feature.NewResourceFeature(version, nil).When(enabled), | ||
| Mutate: func(m *role.Mutator) error { | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.AddRule(rbacv1.PolicyRule{ | ||
| APIGroups: []string{""}, | ||
| Resources: []string{"secrets"}, | ||
| Verbs: []string{"get", "list"}, | ||
| }) | ||
| return nil | ||
| }) | ||
| return nil | ||
| }, | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Version-gated mutations | ||
|
|
||
| ```go | ||
| var legacyConstraint = mustSemverConstraint("< 2.0.0") | ||
|
|
||
| func LegacyRoleMutation(version string) role.Mutation { | ||
| return role.Mutation{ | ||
| Name: "legacy-role", | ||
| Feature: feature.NewResourceFeature( | ||
| version, | ||
| []feature.VersionConstraint{legacyConstraint}, | ||
| ), | ||
| Mutate: func(m *role.Mutator) error { | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.AddRule(rbacv1.PolicyRule{ | ||
| APIGroups: []string{"extensions"}, | ||
| Resources: []string{"ingresses"}, | ||
| Verbs: []string{"get", "list"}, | ||
| }) | ||
| return nil | ||
| }) | ||
| 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 Role | | ||
| | 2 | Rules edits | `.rules` — SetRules, AddRule, Raw | | ||
|
|
||
| Within each category, edits are applied in their registration order. Later features observe the Role as modified by all | ||
| previous features. | ||
|
|
||
| ## Editors | ||
|
|
||
| ### PolicyRulesEditor | ||
|
|
||
| The primary API for modifying `.rules`. Use `m.EditRules` for full control: | ||
|
|
||
| ```go | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.SetRules([]rbacv1.PolicyRule{ | ||
|
Comment on lines
+134
to
+142
|
||
| {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}}, | ||
| }) | ||
| return nil | ||
| }) | ||
| ``` | ||
|
|
||
| #### SetRules | ||
|
|
||
| `SetRules` replaces the entire rules slice atomically. Use this when the mutation should define the complete set of | ||
| rules, discarding any previously accumulated entries. | ||
|
|
||
| ```go | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.SetRules(desiredRules) | ||
| return nil | ||
| }) | ||
| ``` | ||
|
|
||
| #### AddRule | ||
|
|
||
| `AddRule` appends a single rule to the existing rules slice. Use this when a feature contributes additional permissions | ||
| without needing to know about rules from other features. | ||
|
|
||
| ```go | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.AddRule(rbacv1.PolicyRule{ | ||
| APIGroups: []string{""}, | ||
| Resources: []string{"configmaps"}, | ||
| Verbs: []string{"get", "watch"}, | ||
| }) | ||
| return nil | ||
| }) | ||
| ``` | ||
|
|
||
| #### Raw Escape Hatch | ||
|
|
||
| `Raw()` returns a pointer to the underlying `[]rbacv1.PolicyRule` for direct manipulation when none of the structured | ||
| methods are sufficient: | ||
|
|
||
| ```go | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| raw := e.Raw() | ||
| // Filter out rules that grant write access | ||
| filtered := (*raw)[:0] | ||
| for _, r := range *raw { | ||
| if !containsVerb(r.Verbs, "create") { | ||
| filtered = append(filtered, r) | ||
| } | ||
| } | ||
| *raw = filtered | ||
| 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("managed-by", "my-operator") | ||
| return nil | ||
| }) | ||
| ``` | ||
|
|
||
| ## Full Example: Feature-Composed Permissions | ||
|
|
||
| ```go | ||
| func BaseRuleMutation(version string) role.Mutation { | ||
| return role.Mutation{ | ||
| Name: "base-rules", | ||
| Feature: feature.NewResourceFeature(version, nil), | ||
| Mutate: func(m *role.Mutator) error { | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.SetRules([]rbacv1.PolicyRule{ | ||
| {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch"}}, | ||
| }) | ||
| return nil | ||
| }) | ||
| return nil | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func SecretAccessMutation(version string, enabled bool) role.Mutation { | ||
| return role.Mutation{ | ||
| Name: "secret-access", | ||
| Feature: feature.NewResourceFeature(version, nil).When(enabled), | ||
| Mutate: func(m *role.Mutator) error { | ||
| m.EditRules(func(e *editors.PolicyRulesEditor) error { | ||
| e.AddRule(rbacv1.PolicyRule{ | ||
| APIGroups: []string{""}, | ||
| Resources: []string{"secrets"}, | ||
| Verbs: []string{"get", "list"}, | ||
| }) | ||
| return nil | ||
| }) | ||
| return nil | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| resource, err := role.NewBuilder(base). | ||
| WithMutation(BaseRuleMutation(owner.Spec.Version)). | ||
| WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)). | ||
| Build() | ||
| ``` | ||
|
|
||
| When `EnableTracing` is true, the final Role will contain both the base pod rules and the secrets rule. When false, only | ||
| the base rules are applied. 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 `AddRule` for composable permissions.** When multiple features need to contribute rules to the same Role, | ||
| `AddRule` lets each feature add its permissions independently. Using `SetRules` in multiple features means the last | ||
| registration wins — only use that when full replacement is the intended semantics. | ||
|
|
||
| **Register mutations in dependency order.** If mutation B relies on rules set by mutation A, register A first. | ||
|
|
||
| **PolicyRule has no unique key.** There is no upsert or remove-by-key operation. Use `SetRules` to replace atomically, | ||
| `AddRule` to accumulate, or `Raw()` for arbitrary manipulation including filtering. | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,31 @@ | ||||||
| # Role Primitive Example | ||||||
|
|
||||||
| This example demonstrates the usage of the `role` primitive within the operator component framework. It shows how to | ||||||
| manage a Kubernetes Role as a component of a larger application, utilising features like: | ||||||
|
|
||||||
| - **Base Construction**: Initializing a Role with core RBAC permissions. | ||||||
| - **Feature Mutations**: Composing policy rules from independent, feature-gated mutations using `AddRule`. | ||||||
| - **Metadata Mutations**: Setting version labels on the Role via `EditObjectMetadata`. | ||||||
| - **Data Extraction**: Inspecting the reconciled Role's rules after each sync 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 rules, version labelling, and feature-gated secret and metrics access. | ||||||
| - `resources/`: Contains the central `NewRoleResource` factory that assembles all features using `role.Builder`. | ||||||
| - `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. | ||||||
|
|
||||||
| ## Running the Example | ||||||
|
|
||||||
| ```bash | ||||||
| go run examples/role-primitive/main.go | ||||||
|
||||||
| go run examples/role-primitive/main.go | |
| go run ./examples/role-primitive/. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| // Package app provides a sample controller using the role 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 | ||
|
|
||
| // NewRoleResource is a factory function to create the role resource. | ||
| // This allows us to inject the resource construction logic. | ||
| NewRoleResource 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 role resource for this owner. | ||
| roleResource, err := r.NewRoleResource(owner) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| // 2. Build the component that manages the role. | ||
| comp, err := component.NewComponentBuilder(). | ||
| WithName("example-app"). | ||
| WithConditionType("AppReady"). | ||
| WithResource(roleResource, 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) | ||
| } |
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.
The PR description checklist states "Does not modify shared files", but this change updates shared repository files (e.g. Makefile, docs, and shared mutation editors). Please update the PR description/checklist to reflect the actual scope so reviewers have an accurate contract for what the PR changes.