diff --git a/Makefile b/Makefile index b113a3ee..667f21ed 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all -all: fmt lint test build-examples +all: fmt lint test test-examples build-examples ##@ General @@ -121,30 +121,17 @@ test: setup-envtest build-examples: ## Build all example binaries. go build ./examples/... +.PHONY: test-examples +test-examples: ## Run example tests (golden files, mutation unit tests). + go test ./examples/... + .PHONY: run-examples 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/. - go run ./examples/rolebinding-primitive/. - go run ./examples/custom-resource-implementation/. - go run ./examples/service-primitive/. - go run ./examples/role-primitive/. - go run ./examples/pdb-primitive/. - go run ./examples/daemonset-primitive/. - go run ./examples/hpa-primitive/. - go run ./examples/clusterrolebinding-primitive/. - go run ./examples/clusterrole-primitive/. - go run ./examples/cronjob-primitive/. - go run ./examples/ingress-primitive/. - go run ./examples/job-primitive/. - go run ./examples/networkpolicy-primitive/. - go run ./examples/pod-primitive/. - go run ./examples/pvc-primitive/. - go run ./examples/pv-primitive/. + go run ./examples/mutations-and-gating/. + go run ./examples/extraction-and-guards/. + go run ./examples/component-prerequisites/. + go run ./examples/custom-resource/. + go run ./examples/grace-inconsistency/. ##@ E2E Testing diff --git a/docs/guidelines.md b/docs/guidelines.md index 22283e0a..b0fb2937 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -9,6 +9,7 @@ are effective and pitfalls that are easy to walk into. - [One Component Per Logical Condition](#one-component-per-logical-condition) - [Keep Controllers Thin](#keep-controllers-thin) - [Resource Registration Order Is Execution Order](#resource-registration-order-is-execution-order) +- [Mutation Ordering and Container Name Dependencies](#mutation-ordering-and-container-name-dependencies) - [Use Data Extraction and Guards for Resource Dependencies](#use-data-extraction-and-guards-for-resource-dependencies) - [Prefer stable values for guard conditions](#prefer-stable-values-for-guard-conditions) - [Use Prerequisites for Cross-Component Dependencies](#use-prerequisites-for-cross-component-dependencies) @@ -108,7 +109,40 @@ you update the baseline and adjust any mutations that assumed the old shape. The the ones gated on legacy versions, and those mutations are explicitly about backward compatibility rather than silently load-bearing. -### Legacy version mutations in practice +### Revert mutations vs. forward mutations + +The baseline-as-latest approach means that every structural version change requires a new legacy mutation that reverts +the baseline to the older shape. This is real friction: update the baseline, write a revert mutation, add golden files. +It is natural to wonder whether the opposite direction (baseline stays at the original shape, forward mutations patch it +to the latest version) would be less work. + +In practice, the revert direction is easier to maintain: + +- **Adding a revert mutation does not require changing existing ones.** Each revert mutation handles one version step: + the v2 revert turns v3 back into v2, and the v1 revert turns v2 back into v1. They do execute in order (newest first), + but the v1 revert was written when v2 was the baseline, and it still works because the v2 revert restores the shape it + expects. When you drop support for v1, you delete one mutation and nothing else changes. +- **Forward mutations have fragile ordering dependencies.** A v3 forward patch might assume that the v2 patch already + ran (e.g. it expects a container name or port layout that only exists after v2's mutation). Delete the v2 mutation + when you drop support, and v3 breaks silently because its precondition is gone. +- **You read the baseline more often than you update it.** Structural changes to a resource happen occasionally; reading + the resource definition happens constantly. With baseline-as-latest, a new contributor opens the file and sees the + current shape at a glance. With baseline-as-original, understanding the current shape requires mentally replaying + every forward mutation in order. +- **The two mutation categories have different lifecycles.** Revert mutations are backward compatibility: temporary by + nature, and they shrink as you drop old versions. Feature mutations (tracing, metrics, debug logging) are + cross-cutting concerns with a longer lifecycle. Forward mutations mix both categories in the same pipeline, making it + harder to tell which mutations are temporary compatibility shims and which are permanent features. +- **Where the revert approach costs more.** Each structural version change requires writing a new revert mutation. This + is the tradeoff. But the friction is also a forcing function: it makes the backward-compatibility decision explicit + rather than letting old shapes silently persist as the baseline drifts from reality. + +The number of revert mutations is bounded by the number of supported versions. Most operators support two or three +concurrent versions. When a version falls out of support, its revert mutation is deleted cleanly. Forward mutation +stacks tend to grow indefinitely because removing a forward mutation requires proving that nothing downstream depends on +it. + +### Backward compatibility mutations in practice Suppose version 2.0 of your application renamed its container from `"server"` to `"app"` and added a health check port. The baseline reflects the latest shape: @@ -135,17 +169,17 @@ dep := &appsv1.Deployment{ } res, err := deployment.NewBuilder(dep). - WithMutation(LegacyContainerName(owner.Spec.Version)). + WithMutation(BackwardCompatV1Container(owner.Spec.Version)). WithMutation(TracingFeature(owner.Spec.TracingEnabled)). Build() ``` -The legacy mutation rolls the baseline back for older versions: +The backward compat mutation rolls the baseline back for older versions: ```go -func LegacyContainerName(version string) deployment.Mutation { +func BackwardCompatV1Container(version string) deployment.Mutation { return deployment.Mutation{ - Name: "legacy-container-name", + Name: "BackwardCompatV1Container", Feature: feature.NewVersionGate(version, []feature.VersionConstraint{ LessThan("2.0.0"), }), @@ -163,6 +197,9 @@ func LegacyContainerName(version string) deployment.Mutation { } ``` +Naming the function `BackwardCompat` makes the pattern immediately recognizable. When scanning a builder +chain, `BackwardCompatV1Container` tells you exactly what it does and why it exists without reading the implementation. + `LessThan` here is a user-provided implementation of `feature.VersionConstraint` that wraps a semver comparison. The interface requires a single `Enabled(version string) (bool, error)` method, so you can use any semver library to implement your constraints. @@ -176,7 +213,7 @@ backward. If instead you had kept the baseline at the 1.x shape and added a muta future version change would stack another forward-patching mutation on top, and the baseline would never reflect reality. -### Verifying legacy mutations +### Verifying backward compatibility mutations When you update the baseline, you need confidence that older versions still produce the same object they did before. The framework provides a `golden` package for this. `AssertYAML` accepts any resource that implements `PreviewObject`, @@ -213,9 +250,9 @@ func TestDeploymentShape(t *testing.T) { ``` Each version you care about gets a golden file. When the baseline evolves, run `go test -update` to regenerate the -golden files, then review the diff. The current version's golden file updates to reflect the new shape, but legacy -version golden files should stay unchanged. If a baseline change accidentally breaks a legacy mutation, the snapshot -diff shows exactly what shifted. +golden files, then review the diff. The current version's golden file updates to reflect the new shape, but older +version golden files should stay unchanged. If a baseline change accidentally breaks a backward compat mutation, the +snapshot diff shows exactly what shifted. A reasonable heuristic for the boundary: if a field is always present regardless of feature flags or version, it belongs in the baseline. If it is conditional, it belongs in a mutation. @@ -317,6 +354,166 @@ Reading the `WithResource()` calls top to bottom tells you the execution order. reconstruct. The flip side is that reordering these calls can silently break data flow between guards and extractors. Document the dependency when it exists. +## Mutation Ordering and Container Name Dependencies + +Mutations within a resource are also applied in registration order. Each mutation gets its own feature scope, and later +mutations see the resource as modified by all earlier mutations. This is normally invisible because most mutations are +independent. It becomes visible when a backward compat mutation renames a container and a feature mutation needs to +target that container by name. + +Consider a deployment where the baseline container is named `"app"` (v2+), and a backward compat mutation renames it to +`"server"` for versions before 2.0. A new mutation that sets `LOG_LEVEL=debug` on the application container faces a +question: does it target `"app"` or `"server"`? + +The answer depends on registration order, and there are two rules that eliminate the problem. + +### Use broad selectors for version-independent mutations + +If a mutation applies to all versions regardless of container name, use `AllContainers()`, `EnsureContainerEnvVar`, or +`EnsureContainerArg`. These selectors never reference a name, so they work whether or not a backward compat rename has +fired. No ordering constraint is needed. + +```go +// TracingSidecar uses EnsureContainerEnvVar (wraps AllContainers) and is order-insensitive. +func TracingSidecarMutation(enabled bool) deployment.Mutation { + return deployment.Mutation{ + Name: "Tracing", + Feature: feature.NewVersionGate("any", nil).When(enabled), + Mutate: func(m *deployment.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "jaeger-agent", + Image: "jaegertracing/jaeger-agent:1.28", + }) + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "JAEGER_AGENT_HOST", + Value: "localhost", + }) + return nil + }, + } +} +``` + +### Register name-specific mutations before backward compat renames + +When a mutation must target a specific container by name, register it before the backward compat mutation that renames +it. Registered in that position, the mutation sees the baseline name because the rename has not fired yet. Its edits +carry through the rename because the backward compat mutation only overwrites specific fields (`Name`, `Ports`), not the +entire container. + +```go +// DebugLogging targets ContainerNamed("app"), so it must come before BackwardCompatV1Container. +func DebugLoggingMutation(enabled bool) deployment.Mutation { + return deployment.Mutation{ + Name: "DebugLogging", + Feature: feature.NewVersionGate("any", nil).When(enabled), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) + return nil + }) + return nil + }, + } +} +``` + +The registration order makes the constraint explicit: + +```go +res, err := deployment.NewBuilder(BaseDeployment(owner)). + WithMutation(DebugLoggingMutation(owner.Spec.EnableDebugLogging)). // targets "app" by name + WithMutation(BackwardCompatV1Container(owner.Spec.Version)). // renames "app" → "server" for v1 + WithMutation(TracingSidecarMutation(owner.Spec.EnableTracing)). // uses AllContainers, order-insensitive + Build() +``` + +For v2+, the backward compat mutation is inactive and `DebugLogging` sets the env var on `"app"`. For v1, `DebugLogging` +sets the env var on `"app"`, then `BackwardCompatV1Container` renames the container to `"server"` and resets its ports. +The env var survives because the rename does not touch `Env`. + +### Naming and ordering with multiple backward compat mutations + +Name backward compat mutations `BackwardCompat` so the pattern is self-documenting. Use the `P` separator +for version dots when the function name would otherwise be ambiguous: `BackwardCompatV1P2P0Container` for v1.2.0, +`BackwardCompatV2Container` for the v2 line. + +When multiple backward compat mutations exist, register the newest first (closest to the baseline) and the oldest last. +Each mutation reverts one version step: `BackwardCompatV2` reverts v3 to v2, and `BackwardCompatV1` reverts v2 to v1. +For a v1 version, both fire in sequence. The key guarantee is not that they are independent of each other (they do +execute in order), but that adding a new one does not require changing existing ones. When you update the baseline to v3 +and add `BackwardCompatV2`, the existing `BackwardCompatV1` continues to work unchanged because `BackwardCompatV2` +restores the v2 shape before `BackwardCompatV1` runs. + +```go +res, err := deployment.NewBuilder(BaseDeployment(owner)). // baseline is v3 + WithMutation(DebugLoggingMutation(owner.Spec.EnableDebugLogging)). // must come before backward compat renames + WithMutation(BackwardCompatV2Container(owner.Spec.Version)). // reverts v3 → v2 for < 3.0.0 + WithMutation(BackwardCompatV1Container(owner.Spec.Version)). // reverts v2 → v1 for < 2.0.0 + WithMutation(TracingSidecarMutation(owner.Spec.EnableTracing)). // order-insensitive (broad selector) + Build() +``` + +The only ordering constraint for feature mutations is that those targeting a container by name must come before backward +compat mutations that rename that container. Feature mutations that use broad selectors (like `TracingSidecar`) can go +anywhere in the chain. The ordering between feature mutations themselves does not matter. When you add a new version +that changes the resource structure, update the baseline and insert the new backward compat mutation before the existing +ones. + +#### Alternative: non-overlapping version gates + +Instead of chaining reverts (each undoing one version step), you can give each backward compat mutation a +non-overlapping gate so that only one fires for any given version. `BackwardCompatV2` fires for `>= 2.0.0, < 3.0.0` and +`BackwardCompatV1` fires for `>= 1.0.0, < 2.0.0`. Each mutation reverts directly from the current baseline to its target +version, making them truly order-independent. + +The tradeoff is maintenance cost. When you update the baseline to v3, every existing backward compat mutation needs +updating because each one must now revert from the v3 shape instead of the v2 shape. With chained reverts, only the new +mutation needs writing; existing ones are untouched. The chained approach trades a visible ordering constraint (newest +first, enforced by the registration chain) for a stronger stability guarantee (existing mutations never change). The +non-overlapping approach trades that stability for order independence. + +For most operators, the chained approach is the better default. The ordering is mechanical (newest first) and visible in +the builder chain. The non-overlapping approach is worth considering when mutations are complex enough that reasoning +about chained execution is genuinely difficult, or when backward compat mutations are maintained by different teams who +cannot easily coordinate ordering. + +### What to avoid + +Do not work around the ordering problem by matching multiple names: + +```go +// Anti-pattern: couples this mutation to knowledge of legacy naming. +m.EditContainers(selectors.ContainersNamed("app", "server"), func(ce *editors.ContainerEditor) error { + ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) + return nil +}) +``` + +This works today but breaks if a future version renames the container again. The mutation now needs to track every name +the container has ever had. Instead, target the baseline name and register the mutation before the backward compat +rename: + +```go +// Correct: targets the baseline name, registered before BackwardCompatV1Container. +m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) + return nil +}) +``` + +The mutation only knows `"app"` (the current baseline name). The backward compat rename fires afterward and carries the +edit through. + +### When a backward compat mutation replaces the entire container + +The carry-through property depends on the backward compat mutation only overwriting specific fields. If a backward +compat mutation replaces the entire container (sets all fields, not just `Name` and `Ports`), edits from earlier +mutations are lost. In that case, the mutation is effectively a full override and later mutations should target the +post-rename name via version gating rather than relying on ordering. + +See the [mutations-and-gating example](../examples/mutations-and-gating/) for a working demonstration of these patterns. + ## Use Data Extraction and Guards for Resource Dependencies When one resource depends on data from another, use a data extractor on the first resource and a guard on the second. Do diff --git a/docs/primitives.md b/docs/primitives.md index a113f8d8..043a8bfd 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -150,6 +150,8 @@ Selectors determine which containers an editor targets. This is important for mu selectors.AllContainers() // every container in the pod selectors.ContainerNamed("app") // a single container by name selectors.ContainersNamed("web", "api") // multiple containers by name +selectors.ContainerNotNamed("sidecar") // all containers except one +selectors.ContainersNotNamed("agent", "log") // all containers except several selectors.ContainerAtIndex(0) // container at a specific index ``` diff --git a/examples/clusterrole-primitive/README.md b/examples/clusterrole-primitive/README.md deleted file mode 100644 index 1d3457c3..00000000 --- a/examples/clusterrole-primitive/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# ClusterRole Primitive Example - -This example demonstrates the usage of the `clusterrole` primitive within the operator component framework. It shows how -to manage a Kubernetes ClusterRole as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a cluster-scoped ClusterRole with basic metadata. -- **Feature Mutations**: Composing RBAC rules from independent, feature-gated mutations using `AddRule`. -- **Metadata Mutations**: Setting version labels on the ClusterRole via `EditObjectMetadata`. -- **Data Extraction**: Inspecting ClusterRole rules 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`: core rules, version labelling, and feature-gated secret and deployment access. -- `resources/`: Contains the central `NewClusterRoleResource` factory that assembles all features using - `clusterrole.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/clusterrole-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 composed rules after each cycle. -4. Print the resulting status conditions. diff --git a/examples/clusterrole-primitive/app/controller.go b/examples/clusterrole-primitive/app/controller.go deleted file mode 100644 index 1fbf376f..00000000 --- a/examples/clusterrole-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the clusterrole 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 - - // NewClusterRoleResource is a factory function to create the clusterrole resource. - // This allows us to inject the resource construction logic. - NewClusterRoleResource 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 clusterrole resource for this owner. - crResource, err := r.NewClusterRoleResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the clusterrole. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(crResource, 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/clusterrole-primitive/features/mutations.go b/examples/clusterrole-primitive/features/mutations.go deleted file mode 100644 index 7ee73189..00000000 --- a/examples/clusterrole-primitive/features/mutations.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package features provides sample mutations for the clusterrole 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/clusterrole" - rbacv1 "k8s.io/api/rbac/v1" -) - -// CoreRulesMutation grants read access to core resources (pods, services, configmaps). -// It is always enabled — Feature is nil so it applies unconditionally. -func CoreRulesMutation() clusterrole.Mutation { - return clusterrole.Mutation{ - Name: "core-rules", - Mutate: func(m *clusterrole.Mutator) error { - m.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"pods", "services", "configmaps"}, - Verbs: []string{"get", "list", "watch"}, - }) - return nil - }, - } -} - -// VersionLabelMutation sets the app.kubernetes.io/version label on the ClusterRole. -// It is always enabled — Feature is nil so it applies unconditionally. -func VersionLabelMutation(version string) clusterrole.Mutation { - return clusterrole.Mutation{ - Name: "version-label", - Mutate: func(m *clusterrole.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// SecretAccessMutation grants read access to secrets. -// It is enabled when needsSecrets is true. -func SecretAccessMutation(version string, needsSecrets bool) clusterrole.Mutation { - return clusterrole.Mutation{ - Name: "secret-access", - Feature: feature.NewVersionGate(version, nil).When(needsSecrets), - Mutate: func(m *clusterrole.Mutator) error { - m.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{"get", "list"}, - }) - return nil - }, - } -} - -// DeploymentAccessMutation grants read/write access to deployments. -// It is enabled when manageDeployments is true. -func DeploymentAccessMutation(version string, manageDeployments bool) clusterrole.Mutation { - return clusterrole.Mutation{ - Name: "deployment-access", - Feature: feature.NewVersionGate(version, nil).When(manageDeployments), - Mutate: func(m *clusterrole.Mutator) error { - m.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{"apps"}, - Resources: []string{"deployments"}, - Verbs: []string{"get", "list", "watch", "create", "update", "patch"}, - }) - return nil - }, - } -} diff --git a/examples/clusterrole-primitive/main.go b/examples/clusterrole-primitive/main.go deleted file mode 100644 index fa4a5504..00000000 --- a/examples/clusterrole-primitive/main.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package main is the entry point for the clusterrole 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/clusterrole-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/clusterrole-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - rbacv1 "k8s.io/api/rbac/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 := rbacv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add rbac/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&sharedapp.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - // NOTE: This example uses a namespaced owner for simplicity with the fake client. - // The framework detects the scope mismatch between a namespaced owner and - // a cluster-scoped dependent (ClusterRole) and skips setting the controller - // reference (logging a message), so reconciliation still proceeds but without - // garbage collection or owner-based adoption for the ClusterRole. If you want - // owner references and GC/adoption for ClusterRoles in production, use a - // cluster-scoped owner CRD. - 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, - }, - NewClusterRoleResource: resources.NewClusterRoleResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate how - // feature-gated mutations compose RBAC rules from independent features. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable secret access - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: false, // Disable deployment access too - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, SecretAccess=%v, DeploymentAccess=%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/clusterrole-primitive/resources/clusterrole.go b/examples/clusterrole-primitive/resources/clusterrole.go deleted file mode 100644 index 56417487..00000000 --- a/examples/clusterrole-primitive/resources/clusterrole.go +++ /dev/null @@ -1,52 +0,0 @@ -// Package resources provides resource implementations for the clusterrole primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/clusterrole-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/clusterrole" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewClusterRoleResource constructs a clusterrole primitive resource with all the features. -func NewClusterRoleResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base ClusterRole object. - // ClusterRole is cluster-scoped — no namespace is set. - base := &rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-role", - Labels: map[string]string{ - "app": owner.Name, - }, - }, - } - - // 2. Initialize the clusterrole builder. - builder := clusterrole.NewBuilder(base) - - // 3. Register mutations in dependency order. - // CoreRulesMutation and VersionLabelMutation always run first. - // SecretAccess and DeploymentAccess are feature-gated. - builder.WithMutation(features.CoreRulesMutation()) - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)) - builder.WithMutation(features.DeploymentAccessMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled ClusterRole. - builder.WithDataExtractor(func(cr rbacv1.ClusterRole) error { - fmt.Printf("Reconciled ClusterRole: %s\n", cr.Name) - fmt.Printf(" Rules: %d\n", len(cr.Rules)) - for i, rule := range cr.Rules { - fmt.Printf(" [%d] APIGroups=%v Resources=%v Verbs=%v\n", - i, rule.APIGroups, rule.Resources, rule.Verbs) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/clusterrolebinding-primitive/README.md b/examples/clusterrolebinding-primitive/README.md deleted file mode 100644 index 94799b1a..00000000 --- a/examples/clusterrolebinding-primitive/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# ClusterRoleBinding Primitive Example - -This example demonstrates the usage of the `clusterrolebinding` primitive within the operator component framework. It -shows how to manage a Kubernetes ClusterRoleBinding as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a ClusterRoleBinding with a roleRef and base subjects. -- **Feature Mutations**: Adding subjects conditionally via feature-gated mutations using `EditSubjects`. -- **Metadata Mutations**: Setting version labels on the ClusterRoleBinding via `EditObjectMetadata`. -- **Data Extraction**: Inspecting ClusterRoleBinding state 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 and feature-gated monitoring subject addition. -- `resources/`: Contains the central `NewClusterRoleBindingResource` factory that assembles all features using - `clusterrolebinding.Builder`. -- `main.go`: A standalone entry point that demonstrates building and mutating a ClusterRoleBinding through multiple spec - variations. - -## Running the Example - -```bash -go run examples/clusterrolebinding-primitive/main.go -``` - -This will: - -1. Create an in-memory `ExampleApp` owner object. -2. For each of three spec variations, build a fresh resource and apply mutations to a simulated current - ClusterRoleBinding. -3. Print the reconciled ClusterRoleBinding state (labels, roleRef, subjects) after each mutation cycle. diff --git a/examples/clusterrolebinding-primitive/app/controller.go b/examples/clusterrolebinding-primitive/app/controller.go deleted file mode 100644 index 617ced24..00000000 --- a/examples/clusterrolebinding-primitive/app/controller.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package app provides a sample controller using the clusterrolebinding primitive. -// -// Note: ClusterRoleBinding is cluster-scoped. When the owner is namespace-scoped, -// the component framework automatically skips setting a controller owner reference -// (since Kubernetes does not allow cross-scope owner references) and logs an info -// message. This means the ClusterRoleBinding will not be garbage-collected when -// the owner is deleted — operators should implement their own cleanup logic if -// needed. In production, using a cluster-scoped CRD as the owner avoids this -// limitation entirely. -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. -// -// When the owner is namespace-scoped, the framework skips the controller owner -// reference for cluster-scoped resources. Use a cluster-scoped owner CRD in -// production if automatic garbage collection is required. -type ExampleController struct { - client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - Metrics component.Recorder - - // NewClusterRoleBindingResource is a factory function to create the clusterrolebinding resource. - NewClusterRoleBindingResource 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 clusterrolebinding resource for this owner. - crbResource, err := r.NewClusterRoleBindingResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the clusterrolebinding. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(crbResource, 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/clusterrolebinding-primitive/features/mutations.go b/examples/clusterrolebinding-primitive/features/mutations.go deleted file mode 100644 index 9db0179a..00000000 --- a/examples/clusterrolebinding-primitive/features/mutations.go +++ /dev/null @@ -1,40 +0,0 @@ -// Package features provides sample mutations for the clusterrolebinding 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/clusterrolebinding" -) - -// VersionLabelMutation sets the app.kubernetes.io/version label on the -// ClusterRoleBinding. It is always enabled. -func VersionLabelMutation(version string) clusterrolebinding.Mutation { - return clusterrolebinding.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *clusterrolebinding.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// MonitoringSubjectMutation adds a monitoring service account as a subject -// when metrics are enabled. -func MonitoringSubjectMutation(version string, enableMetrics bool) clusterrolebinding.Mutation { - return clusterrolebinding.Mutation{ - Name: "monitoring-subject", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), - Mutate: func(m *clusterrolebinding.Mutator) error { - m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureServiceAccount("monitoring-agent", "monitoring") - return nil - }) - return nil - }, - } -} diff --git a/examples/clusterrolebinding-primitive/main.go b/examples/clusterrolebinding-primitive/main.go deleted file mode 100644 index d2f75c97..00000000 --- a/examples/clusterrolebinding-primitive/main.go +++ /dev/null @@ -1,100 +0,0 @@ -// Package main is the entry point for the clusterrolebinding primitive example. -package main - -import ( - "fmt" - "os" - - "github.com/sourcehawk/operator-component-framework/examples/clusterrolebinding-primitive/features" - "github.com/sourcehawk/operator-component-framework/examples/clusterrolebinding-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func main() { - // 1. Create an example Owner object. - owner := &sharedapp.ExampleApp{ - Spec: sharedapp.ExampleAppSpec{ - Version: "1.2.3", - EnableMetrics: true, - }, - } - owner.Name = "my-example-app" - owner.Namespace = "default" - - // 2. Demonstrate building and mutating a ClusterRoleBinding through - // multiple spec variations. Each step builds a fresh resource and - // applies mutations to a simulated current object. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableMetrics: true, - }, - { - Version: "1.2.4", // Version upgrade - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableMetrics: false, // Disable metrics — monitoring subject removed - }, - } - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v ---\n", - i+1, spec.Version, spec.EnableMetrics) - - owner.Spec = spec - - // Build the resource from the factory. - resource, err := resources.NewClusterRoleBindingResource(owner) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to build resource: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Identity: %s\n", resource.Identity()) - - // Simulate a current cluster object. - current := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-binding", - ResourceVersion: "12345", - Labels: map[string]string{"external-controller": "managed"}, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: owner.Name + "-role", - }, - } - - // Apply mutations to the current object. - if err := resource.Mutate(current); err != nil { - fmt.Fprintf(os.Stderr, "mutation failed: %v\n", err) - os.Exit(1) - } - - // Print the result. - fmt.Printf("Labels: %v\n", current.Labels) - fmt.Printf("RoleRef: %s/%s\n", current.RoleRef.Kind, current.RoleRef.Name) - fmt.Printf("Subjects (%d):\n", len(current.Subjects)) - for _, s := range current.Subjects { - fmt.Printf(" - %s %s/%s\n", s.Kind, s.Namespace, s.Name) - } - - // Extract data. - if err := resource.ExtractData(); err != nil { - fmt.Fprintf(os.Stderr, "data extraction failed: %v\n", err) - os.Exit(1) - } - } - - // 3. Demonstrate the version label mutation independently. - fmt.Println("\n--- Standalone mutation demo ---") - mutation := features.VersionLabelMutation("2.0.0") - fmt.Printf("Mutation name: %s\n", mutation.Name) - - fmt.Println("\nExample completed successfully!") -} diff --git a/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go b/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go deleted file mode 100644 index d402c1c2..00000000 --- a/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package resources provides resource implementations for the clusterrolebinding primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/clusterrolebinding-primitive/features" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrolebinding" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewClusterRoleBindingResource constructs a clusterrolebinding primitive resource -// with all the features. -func NewClusterRoleBindingResource(owner *sharedapp.ExampleApp) (*clusterrolebinding.Resource, error) { - // 1. Create the base ClusterRoleBinding object. - base := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-binding", - Labels: map[string]string{ - "app": owner.Name, - }, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: owner.Name + "-role", - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: owner.Name, - Namespace: owner.Namespace, - }, - }, - } - - // 2. Initialize the clusterrolebinding builder. - builder := clusterrolebinding.NewBuilder(base) - - // 3. Register mutations in dependency order. - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled ClusterRoleBinding. - builder.WithDataExtractor(func(crb rbacv1.ClusterRoleBinding) error { - fmt.Printf("Reconciled ClusterRoleBinding: %s\n", crb.Name) - fmt.Printf(" RoleRef: %s/%s\n", crb.RoleRef.Kind, crb.RoleRef.Name) - fmt.Printf(" Subjects (%d):\n", len(crb.Subjects)) - for _, s := range crb.Subjects { - fmt.Printf(" - %s %s/%s\n", s.Kind, s.Namespace, s.Name) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/component-prerequisites/README.md b/examples/component-prerequisites/README.md new file mode 100644 index 00000000..2266e1ca --- /dev/null +++ b/examples/component-prerequisites/README.md @@ -0,0 +1,26 @@ +# Component Prerequisites + +This example demonstrates how to use **component-level prerequisites** with `DependsOn` to order components within a +controller. + +## What it shows + +- **Two components, one controller**: The "infra" component manages a ConfigMap and reports `InfraReady`. The "app" + component manages a Deployment and reports `AppReady`. +- **DependsOn prerequisite**: The app component uses `WithPrerequisite(component.DependsOn("InfraReady"))` to block + until the infra component's condition is `True`. +- **Permanent pass**: Once a prerequisite is satisfied, it is never re-evaluated. Subsequent reconciles proceed without + checking it again. +- **Sequential reconciliation**: The controller reconciles infra first, then app. The owner's in-memory status is + updated between the two calls, so `DependsOn` can read the first component's condition. + +## Reconciliation steps + +1. Full reconciliation: infra sets `InfraReady=True`, then app's prerequisite passes and it proceeds. +2. Version upgrade: both components reconcile normally (prerequisite already passed). + +## Running + +```bash +go run ./examples/component-prerequisites/. +``` diff --git a/examples/component-prerequisites/app/controller.go b/examples/component-prerequisites/app/controller.go new file mode 100644 index 00000000..91f04406 --- /dev/null +++ b/examples/component-prerequisites/app/controller.go @@ -0,0 +1,76 @@ +// Package app provides a sample controller demonstrating component-level prerequisites. +package app + +import ( + "context" + + "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" +) + +// Controller reconciles an ExampleApp using two components: +// - "infra" manages a ConfigMap and reports the InfraReady condition. +// - "app" manages a Deployment and depends on InfraReady via a prerequisite. +// +// The controller reconciles both components in sequence. The app component +// will not proceed until the infra component's condition is True. +type Controller struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + NewConfigMapResource func(*ExampleApp) (component.Resource, error) + NewDeploymentResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile builds and reconciles the infra and app components in order. +func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error { + recCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + // --- Infra component: no prerequisites --- + cmResource, err := r.NewConfigMapResource(owner) + if err != nil { + return err + } + + infra, err := component.NewComponentBuilder(). + WithName("infra"). + WithConditionType("InfraReady"). + WithResource(cmResource, component.ResourceOptions{}). + Build() + if err != nil { + return err + } + + if err := infra.Reconcile(ctx, recCtx); err != nil { + return err + } + + // --- App component: depends on InfraReady --- + deployResource, err := r.NewDeploymentResource(owner) + if err != nil { + return err + } + + app, err := component.NewComponentBuilder(). + WithName("app"). + WithConditionType("AppReady"). + WithResource(deployResource, component.ResourceOptions{}). + WithPrerequisite(component.DependsOn("InfraReady")). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + return app.Reconcile(ctx, recCtx) +} diff --git a/examples/daemonset-primitive/app/owner.go b/examples/component-prerequisites/app/owner.go similarity index 100% rename from examples/daemonset-primitive/app/owner.go rename to examples/component-prerequisites/app/owner.go diff --git a/examples/component-prerequisites/main.go b/examples/component-prerequisites/main.go new file mode 100644 index 00000000..2d420afd --- /dev/null +++ b/examples/component-prerequisites/main.go @@ -0,0 +1,99 @@ +// Package main demonstrates component-level prerequisites with DependsOn. +// +// Two components share one owner. The "infra" component manages a ConfigMap and +// reports InfraReady. The "app" component manages a Deployment and uses +// DependsOn("InfraReady") to wait for the infra component before proceeding. +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/component-prerequisites/app" + "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/record" +) + +func main() { + scheme := runtime.NewScheme() + mustAddToScheme(scheme, app.AddToScheme) + mustAddToScheme(scheme, appsv1.AddToScheme) + mustAddToScheme(scheme, corev1.AddToScheme) + + fakeClient := sharedapp.NewFakeClient(scheme, []sharedapp.RESTMapperEntry{ + {GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, Scope: meta.RESTScopeNamespace}, + {GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, Scope: meta.RESTScopeNamespace}, + {GVK: sharedapp.GroupVersion.WithKind("ExampleApp"), Scope: meta.RESTScopeNamespace}, + }) + + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + + ctx := context.Background() + if err := fakeClient.Create(ctx, owner); err != nil { + exit("failed to create owner: %v", err) + } + + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.Controller{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example", + OperatorConditionsGauge: gauge, + }, + NewConfigMapResource: resources.NewConfigMapResource, + NewDeploymentResource: resources.NewDeploymentResource, + } + + // Step 1: Full reconciliation. Infra runs first and sets InfraReady=True, + // then the app component's prerequisite passes and it proceeds. + fmt.Println("--- Step 1: Full reconciliation ---") + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + // Step 2: Version upgrade. The prerequisite was already satisfied, so it is + // never re-evaluated. Both components reconcile normally. + fmt.Println("\n--- Step 2: Version upgrade ---") + owner.Spec.Version = "1.1.0" + if err := fakeClient.Update(ctx, owner); err != nil { + exit("failed to update owner: %v", err) + } + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + fmt.Println("\nDone.") +} + +func printConditions(owner *app.ExampleApp) { + for _, c := range owner.Status.Conditions { + fmt.Printf(" Condition: %s Status: %s Reason: %s\n", c.Type, c.Status, c.Reason) + } +} + +func mustAddToScheme(scheme *runtime.Scheme, fn func(*runtime.Scheme) error) { + if err := fn(scheme); err != nil { + exit("failed to add to scheme: %v", err) + } +} + +func exit(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/examples/component-prerequisites/resources/configmap.go b/examples/component-prerequisites/resources/configmap.go new file mode 100644 index 00000000..7f1d4c7a --- /dev/null +++ b/examples/component-prerequisites/resources/configmap.go @@ -0,0 +1,29 @@ +// Package resources provides resource factories for the component-prerequisites example. +package resources + +import ( + "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BaseConfigMap returns the desired-state ConfigMap for the infra component. +func BaseConfigMap(owner *app.ExampleApp) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-infra-config", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + Data: map[string]string{ + "cluster-dns": "10.96.0.10", + }, + } +} + +// NewConfigMapResource constructs a ConfigMap for the infra component. +func NewConfigMapResource(owner *app.ExampleApp) (component.Resource, error) { + return configmap.NewBuilder(BaseConfigMap(owner)).Build() +} diff --git a/examples/component-prerequisites/resources/configmap_test.go b/examples/component-prerequisites/resources/configmap_test.go new file mode 100644 index 00000000..8c77efa0 --- /dev/null +++ b/examples/component-prerequisites/resources/configmap_test.go @@ -0,0 +1,39 @@ +package resources_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var update = flag.Bool("update", false, "update golden files") + +func testOwner(version string) *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: version}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +// TestConfigMapShape pins the infra component's ConfigMap baseline. Changes +// to the base object surface as a diff against the golden file. +func TestConfigMapShape(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + owner := testOwner("1.0.0") + res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).Build() + require.NoError(t, err) + + golden.AssertYAML(t, "testdata/configmap.yaml", res, + golden.WithScheme(scheme), golden.Update(*update)) +} diff --git a/examples/component-prerequisites/resources/deployment.go b/examples/component-prerequisites/resources/deployment.go new file mode 100644 index 00000000..ae29ba3c --- /dev/null +++ b/examples/component-prerequisites/resources/deployment.go @@ -0,0 +1,62 @@ +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BaseDeployment returns the desired-state Deployment for the app component. +func BaseDeployment(owner *app.ExampleApp) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-app", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": owner.Name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": owner.Name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "my-app:latest", + }, + }, + }, + }, + }, + } +} + +// NewDeploymentResource constructs a Deployment for the app component. +func NewDeploymentResource(owner *app.ExampleApp) (component.Resource, error) { + builder := deployment.NewBuilder(BaseDeployment(owner)) + builder.WithMutation(deployment.Mutation{ + Name: "Version", + Feature: feature.NewVersionGate(owner.Spec.Version, nil), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-app:%s", owner.Spec.Version) + return nil + }) + return nil + }, + }) + + return builder.Build() +} diff --git a/examples/component-prerequisites/resources/deployment_test.go b/examples/component-prerequisites/resources/deployment_test.go new file mode 100644 index 00000000..35822ba4 --- /dev/null +++ b/examples/component-prerequisites/resources/deployment_test.go @@ -0,0 +1,65 @@ +package resources_test + +import ( + "fmt" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// TestDeploymentShape verifies the app component's Deployment for each version. +// The baseline carries the latest container layout; the version mutation sets +// the image tag. Golden files for each version catch regressions when the +// baseline or mutation logic changes. +func TestDeploymentShape(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + + tests := []struct { + name string + version string + golden string + }{ + { + name: "v1.0.0", + version: "1.0.0", + golden: "testdata/deployment-v1.yaml", + }, + { + name: "v2.0.0", + version: "2.0.0", + golden: "testdata/deployment-v2.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := testOwner(tt.version) + + res, err := deployment.NewBuilder(resources.BaseDeployment(owner)). + WithMutation(deployment.Mutation{ + Name: "Version", + Feature: feature.NewVersionGate(tt.version, nil), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-app:%s", tt.version) + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update)) + }) + } +} diff --git a/examples/component-prerequisites/resources/testdata/configmap.yaml b/examples/component-prerequisites/resources/testdata/configmap.yaml new file mode 100644 index 00000000..3c257dea --- /dev/null +++ b/examples/component-prerequisites/resources/testdata/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +data: + cluster-dns: 10.96.0.10 +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-infra-config + namespace: default diff --git a/examples/component-prerequisites/resources/testdata/deployment-v1.yaml b/examples/component-prerequisites/resources/testdata/deployment-v1.yaml new file mode 100644 index 00000000..fa6cf1c8 --- /dev/null +++ b/examples/component-prerequisites/resources/testdata/deployment-v1.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - image: my-app:1.0.0 + name: app + resources: {} diff --git a/examples/component-prerequisites/resources/testdata/deployment-v2.yaml b/examples/component-prerequisites/resources/testdata/deployment-v2.yaml new file mode 100644 index 00000000..ea27abe7 --- /dev/null +++ b/examples/component-prerequisites/resources/testdata/deployment-v2.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - image: my-app:2.0.0 + name: app + resources: {} diff --git a/examples/configmap-primitive/README.md b/examples/configmap-primitive/README.md deleted file mode 100644 index 79f4c083..00000000 --- a/examples/configmap-primitive/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# ConfigMap Primitive Example - -This example demonstrates the usage of the `configmap` primitive within the operator component framework. It shows how -to manage a Kubernetes ConfigMap as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a ConfigMap with basic metadata. -- **Feature Mutations**: Composing YAML configuration from independent, feature-gated mutations using `MergeYAML`. -- **Metadata Mutations**: Setting version labels on the ConfigMap via `EditObjectMetadata`. -- **Data Extraction**: Harvesting ConfigMap entries after each reconcile cycle. - -## Directory Structure - -- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from - `examples/shared/app`. -- `features/`: Contains modular feature definitions: - - `mutations.go`: base config, version labelling, and feature-gated tracing and metrics sections. -- `resources/`: Contains the central `NewConfigMapResource` factory that assembles all features using - `configmap.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/configmap-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 composed `app.yaml` after each cycle. -4. Print the resulting status conditions. diff --git a/examples/configmap-primitive/app/controller.go b/examples/configmap-primitive/app/controller.go deleted file mode 100644 index 82c00fb9..00000000 --- a/examples/configmap-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the configmap 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 - - // NewConfigMapResource is a factory function to create the configmap resource. - // This allows us to inject the resource construction logic. - NewConfigMapResource 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 configmap resource for this owner. - cmResource, err := r.NewConfigMapResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the configmap. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(cmResource, 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/configmap-primitive/features/mutations.go b/examples/configmap-primitive/features/mutations.go deleted file mode 100644 index e8114289..00000000 --- a/examples/configmap-primitive/features/mutations.go +++ /dev/null @@ -1,78 +0,0 @@ -// Package features provides sample mutations for the configmap 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/configmap" -) - -// BaseConfigMutation writes the application's core server settings into app.yaml. -// It is always enabled. -func BaseConfigMutation(version string) configmap.Mutation { - return configmap.Mutation{ - Name: "base-config", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *configmap.Mutator) error { - m.MergeYAML("app.yaml", ` -server: - port: 8080 - timeout: 30s -`) - return nil - }, - } -} - -// VersionLabelMutation sets the app.kubernetes.io/version label on the ConfigMap -// and records the version in app.yaml. It is always enabled. -func VersionLabelMutation(version string) configmap.Mutation { - return configmap.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *configmap.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - m.MergeYAML("app.yaml", "app:\n version: "+version+"\n") - return nil - }, - } -} - -// TracingConfigMutation adds an OpenTelemetry tracing section to app.yaml. -// It is enabled when enableTracing is true. -func TracingConfigMutation(version string, enableTracing bool) configmap.Mutation { - return configmap.Mutation{ - Name: "tracing-config", - Feature: feature.NewVersionGate(version, nil).When(enableTracing), - Mutate: func(m *configmap.Mutator) error { - m.MergeYAML("app.yaml", ` -tracing: - enabled: true - endpoint: otel-collector:4317 - sampling_rate: 0.1 -`) - return nil - }, - } -} - -// MetricsConfigMutation adds a Prometheus metrics section to app.yaml. -// It is enabled when enableMetrics is true. -func MetricsConfigMutation(version string, enableMetrics bool) configmap.Mutation { - return configmap.Mutation{ - Name: "metrics-config", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), - Mutate: func(m *configmap.Mutator) error { - m.MergeYAML("app.yaml", ` -metrics: - enabled: true - port: 9090 - path: /metrics -`) - return nil - }, - } -} diff --git a/examples/configmap-primitive/main.go b/examples/configmap-primitive/main.go deleted file mode 100644 index 6a76d1ee..00000000 --- a/examples/configmap-primitive/main.go +++ /dev/null @@ -1,115 +0,0 @@ -// Package main is the entry point for the configmap 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/configmap-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/configmap-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, - }, - NewConfigMapResource: resources.NewConfigMapResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate how - // MergeYAML composes config sections from independent feature mutations. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: false, // Disable metrics too - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Tracing=%v, Metrics=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics) - - owner.Spec = spec - if err := fakeClient.Update(ctx, owner); err != nil { - fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) - os.Exit(1) - } - - fmt.Println("Running reconciliation...") - if err := controller.Reconcile(ctx, owner); err != nil { - fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) - os.Exit(1) - } - - for _, cond := range owner.Status.Conditions { - fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", - cond.Type, cond.Status, cond.Reason) - } - } - - fmt.Println("\nReconciliation sequence completed successfully!") -} diff --git a/examples/configmap-primitive/resources/configmap.go b/examples/configmap-primitive/resources/configmap.go deleted file mode 100644 index 52fcd3d6..00000000 --- a/examples/configmap-primitive/resources/configmap.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package resources provides resource implementations for the configmap primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/configmap-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/configmap" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewConfigMapResource constructs a configmap primitive resource with all the features. -func NewConfigMapResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base ConfigMap object. - // - // app.yaml is initialised to an empty string to declare operator ownership of - // that key. An empty value is sufficient to signal ownership and prevent the - // live cluster value from bleeding into the next reconcile cycle when a - // feature is toggled off. - base := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-config", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Data: map[string]string{ - "app.yaml": "", - }, - } - - // 2. Initialize the configmap builder. - builder := configmap.NewBuilder(base) - - // 3. Register mutations in dependency order. - // - // BaseConfigMutation and VersionLabelMutation always run first to establish - // the baseline. Tracing and metrics sections are then merged on top. - builder.WithMutation(features.BaseConfigMutation(owner.Spec.Version)) - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.TracingConfigMutation(owner.Spec.Version, owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsConfigMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled ConfigMap. - builder.WithDataExtractor(func(cm corev1.ConfigMap) error { - fmt.Printf("Reconciled ConfigMap: %s\n", cm.Name) - for key, value := range cm.Data { - fmt.Printf(" [%s]:\n%s\n", key, value) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/cronjob-primitive/README.md b/examples/cronjob-primitive/README.md deleted file mode 100644 index b6996d4d..00000000 --- a/examples/cronjob-primitive/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# CronJob Primitive Example - -This example demonstrates the usage of the `cronjob` primitive within the operator component framework. It shows how to -manage a Kubernetes CronJob as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing a CronJob with a schedule, job template, and containers. -- **Feature Mutations**: Applying conditional changes (tracing env vars, metrics annotations, version-based image - updates) using the `Mutator`. -- **Suspension**: Suspending the CronJob by setting `spec.suspend = true`. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: tracing env vars, metrics annotations, and version-based image updates. -- `resources/`: Contains the central `NewCronJobResource` factory that assembles all features using the - `cronjob.Builder`. -- `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/cronjob-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components through multiple spec changes. -4. Print the resulting status conditions. diff --git a/examples/cronjob-primitive/app/controller.go b/examples/cronjob-primitive/app/controller.go deleted file mode 100644 index b5c469a1..00000000 --- a/examples/cronjob-primitive/app/controller.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package app provides a sample controller using the cronjob 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" -) - -// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. -type ExampleApp = sharedapp.ExampleApp - -// ExampleAppSpec re-exports the shared spec type. -type ExampleAppSpec = sharedapp.ExampleAppSpec - -// ExampleAppStatus re-exports the shared status type. -type ExampleAppStatus = sharedapp.ExampleAppStatus - -// ExampleAppList re-exports the shared list type. -type ExampleAppList = sharedapp.ExampleAppList - -// AddToScheme registers the ExampleApp types with the given scheme. -var AddToScheme = sharedapp.AddToScheme - -// ExampleController reconciles an ExampleApp object using the component framework. -type ExampleController struct { - client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - Metrics component.Recorder - - // NewCronJobResource is a factory function to create the cronjob resource. - NewCronJobResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the cronjob resource for this owner. - cronJobResource, err := r.NewCronJobResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the cronjob. - comp, err := component.NewComponentBuilder(). - WithName("example-cronjob"). - WithConditionType("CronJobReady"). - WithResource(cronJobResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/cronjob-primitive/features/mutations.go b/examples/cronjob-primitive/features/mutations.go deleted file mode 100644 index 8516c085..00000000 --- a/examples/cronjob-primitive/features/mutations.go +++ /dev/null @@ -1,68 +0,0 @@ -// Package features contains example CronJob mutation features. -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" - corev1 "k8s.io/api/core/v1" -) - -// TracingFeature adds tracing environment variables to all containers. -func TracingFeature(enabled bool) cronjob.Mutation { - return cronjob.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *cronjob.Mutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "JAEGER_AGENT_HOST", - Value: "localhost", - }) - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "TRACING_ENABLED", - Value: "true", - }) - return nil - }, - } -} - -// MetricsFeature adds metrics annotations to the pod template. -func MetricsFeature(enabled bool) cronjob.Mutation { - return cronjob.Mutation{ - Name: "Metrics", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *cronjob.Mutator) error { - m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("prometheus.io/scrape", "true") - meta.EnsureAnnotation("prometheus.io/port", "9090") - return nil - }) - return nil - }, - } -} - -// VersionFeature sets the image version and a label. -func VersionFeature(version string) cronjob.Mutation { - return cronjob.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *cronjob.Mutator) error { - m.EditContainers(selectors.ContainerNamed("worker"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-worker:%s", version) - return nil - }) - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - - return nil - }, - } -} diff --git a/examples/cronjob-primitive/main.go b/examples/cronjob-primitive/main.go deleted file mode 100644 index 96705a5f..00000000 --- a/examples/cronjob-primitive/main.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package main is the entry point for the cronjob 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/cronjob-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/resources" - batchv1 "k8s.io/api/batch/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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := batchv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add batch/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the cronjob resource factory. - NewCronJobResource: resources.NewCronJobResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: true, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/cronjob-primitive/resources/cronjob.go b/examples/cronjob-primitive/resources/cronjob.go deleted file mode 100644 index 12cd20dc..00000000 --- a/examples/cronjob-primitive/resources/cronjob.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package resources provides resource implementations for the cronjob primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/cronjob-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/cronjob" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -// NewCronJobResource constructs a cronjob primitive resource with all the features. -func NewCronJobResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base CronJob object. - base := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-cronjob", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: batchv1.CronJobSpec{ - Schedule: "0 2 * * *", - ConcurrencyPolicy: batchv1.ForbidConcurrent, - JobTemplate: batchv1.JobTemplateSpec{ - Spec: batchv1.JobSpec{ - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "worker", - Image: "my-worker:latest", // Will be overwritten by VersionFeature - }, - }, - RestartPolicy: corev1.RestartPolicyOnFailure, - }, - }, - }, - }, - }, - } - - // 2. Initialize the cronjob builder. - builder := cronjob.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionFeature(owner.Spec.Version)) - builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics)) - - // 4. Data extraction (optional). - builder.WithDataExtractor(func(cj batchv1.CronJob) error { - fmt.Printf("Reconciling CronJob: %s, schedule: %s\n", cj.Name, cj.Spec.Schedule) - - y, err := yaml.Marshal(cj) - if err != nil { - return fmt.Errorf("failed to marshal cronjob to yaml: %w", err) - } - fmt.Printf("Complete CronJob Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/custom-resource/README.md b/examples/custom-resource/README.md new file mode 100644 index 00000000..e3c64289 --- /dev/null +++ b/examples/custom-resource/README.md @@ -0,0 +1,30 @@ +# Custom Resource + +This example demonstrates how to manage a **custom resource** (CRD) that has no typed primitive in the framework, using +the **unstructured static builder**. + +## What it shows + +- **Unstructured builder**: `unstructured/static.NewBuilder` wraps an `*unstructured.Unstructured` object, giving it the + same mutation and extraction capabilities as any typed primitive. +- **Content mutations**: `EditContent` with `UnstructuredContentEditor` sets nested spec fields (`issuerRef`, + `dnsNames`) using structured helpers rather than raw map manipulation. +- **Metadata mutations**: `EditObjectMetadata` works the same way as on typed primitives. +- **Data extraction**: `WithDataExtractor` reads fields from the reconciled unstructured object. + +## Use case + +When your operator needs to manage a third-party CRD (cert-manager Certificate, Istio VirtualService, etc.) that has no +typed primitive wrapper in the framework, the unstructured builder is the escape hatch. You get the same reconciliation, +mutation, and extraction patterns without writing a full typed primitive. + +## Reconciliation steps + +1. Create the CertificateRequest with DNS names and issuer reference. +2. Steady-state reconciliation. + +## Running + +```bash +go run ./examples/custom-resource/. +``` diff --git a/examples/custom-resource/app/controller.go b/examples/custom-resource/app/controller.go new file mode 100644 index 00000000..dfd16dd9 --- /dev/null +++ b/examples/custom-resource/app/controller.go @@ -0,0 +1,48 @@ +// Package app provides a sample controller demonstrating custom resource management +// via the unstructured static builder. +package app + +import ( + "context" + + "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" +) + +// Controller reconciles an ExampleApp by managing a CertificateRequest custom +// resource using the unstructured static builder. +type Controller struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + NewCertificateResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile builds and reconciles a single component managing the certificate. +func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error { + certResource, err := r.NewCertificateResource(owner) + if err != nil { + return err + } + + comp, err := component.NewComponentBuilder(). + WithName("certificate"). + WithConditionType("CertificateReady"). + WithResource(certResource, component.ResourceOptions{}). + Build() + if err != nil { + return err + } + + return comp.Reconcile(ctx, component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + }) +} diff --git a/examples/deployment-primitive/app/owner.go b/examples/custom-resource/app/owner.go similarity index 100% rename from examples/deployment-primitive/app/owner.go rename to examples/custom-resource/app/owner.go diff --git a/examples/custom-resource/main.go b/examples/custom-resource/main.go new file mode 100644 index 00000000..9a74d452 --- /dev/null +++ b/examples/custom-resource/main.go @@ -0,0 +1,104 @@ +// Package main demonstrates managing a custom resource using the unstructured +// static builder. +// +// When a CRD has no typed primitive in the framework (e.g., a third-party +// CertificateRequest), the unstructured builder lets you manage it with the +// same mutation and extraction patterns as any built-in primitive. +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/custom-resource/app" + "github.com/sourcehawk/operator-component-framework/examples/custom-resource/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "k8s.io/apimachinery/pkg/api/meta" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/record" +) + +func main() { + scheme := runtime.NewScheme() + mustAddToScheme(scheme, app.AddToScheme) + + // Register the unstructured CertificateRequest type so the fake client + // can round-trip it. + scheme.AddKnownTypeWithName( + resources.CertificateRequestGVK, + &uns.Unstructured{}, + ) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{ + Group: resources.CertificateRequestGVK.Group, + Version: resources.CertificateRequestGVK.Version, + Kind: resources.CertificateRequestGVK.Kind + "List", + }, + &uns.UnstructuredList{}, + ) + + fakeClient := sharedapp.NewFakeClient(scheme, []sharedapp.RESTMapperEntry{ + {GVK: resources.CertificateRequestGVK, Scope: meta.RESTScopeNamespace}, + {GVK: sharedapp.GroupVersion.WithKind("ExampleApp"), Scope: meta.RESTScopeNamespace}, + }) + + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + + ctx := context.Background() + if err := fakeClient.Create(ctx, owner); err != nil { + exit("failed to create owner: %v", err) + } + + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.Controller{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example", + OperatorConditionsGauge: gauge, + }, + NewCertificateResource: resources.NewCertificateResource, + } + + // Step 1: Create the CertificateRequest. + fmt.Println("--- Step 1: Create certificate ---") + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + // Step 2: Reconcile again (steady state). + fmt.Println("\n--- Step 2: Steady-state reconciliation ---") + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + fmt.Println("\nDone.") +} + +func printConditions(owner *app.ExampleApp) { + for _, c := range owner.Status.Conditions { + fmt.Printf(" Condition: %s Status: %s Reason: %s\n", c.Type, c.Status, c.Reason) + } +} + +func mustAddToScheme(scheme *runtime.Scheme, fn func(*runtime.Scheme) error) { + if err := fn(scheme); err != nil { + exit("failed to add to scheme: %v", err) + } +} + +func exit(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/examples/custom-resource/resources/certificate.go b/examples/custom-resource/resources/certificate.go new file mode 100644 index 00000000..5a20cb9e --- /dev/null +++ b/examples/custom-resource/resources/certificate.go @@ -0,0 +1,71 @@ +// Package resources provides resource factories for the custom-resource example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/custom-resource/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + unstruct "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/static" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// CertificateRequestGVK is the GVK for the fictional CertificateRequest CRD. +var CertificateRequestGVK = schema.GroupVersionKind{ + Group: "cert.example.io", + Version: "v1", + Kind: "CertificateRequest", +} + +// BaseCertificateRequest returns the desired-state unstructured object for a +// CertificateRequest. This is the baseline before mutations are applied. +func BaseCertificateRequest(owner *app.ExampleApp) *uns.Unstructured { + obj := &uns.Unstructured{} + obj.SetGroupVersionKind(CertificateRequestGVK) + obj.SetName(owner.Name + "-cert") + obj.SetNamespace(owner.Namespace) + return obj +} + +// NewCertificateResource constructs an unstructured CertificateRequest with +// mutations that set the spec fields. This demonstrates how to manage any CRD +// without a typed primitive wrapper. +func NewCertificateResource(owner *app.ExampleApp) (component.Resource, error) { + builder := static.NewBuilder(BaseCertificateRequest(owner)) + + builder.WithMutation(unstruct.Mutation{ + Name: "certificate-spec", + Mutate: func(m *unstruct.Mutator) error { + m.EditContent(func(e *editors.UnstructuredContentEditor) error { + if err := e.SetNestedString("letsencrypt-prod", "spec", "issuerRef", "name"); err != nil { + return err + } + if err := e.SetNestedString("ClusterIssuer", "spec", "issuerRef", "kind"); err != nil { + return err + } + return e.SetNestedSlice( + []interface{}{owner.Name + ".example.com"}, + "spec", "dnsNames", + ) + }) + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("app", owner.Name) + return nil + }) + + return nil + }, + }) + + builder.WithDataExtractor(func(obj uns.Unstructured) error { + dnsNames, _, _ := uns.NestedStringSlice(obj.Object, "spec", "dnsNames") + fmt.Printf(" Certificate DNS names: %v\n", dnsNames) + return nil + }) + + return builder.Build() +} diff --git a/examples/custom-resource/resources/certificate_test.go b/examples/custom-resource/resources/certificate_test.go new file mode 100644 index 00000000..7f7c5dce --- /dev/null +++ b/examples/custom-resource/resources/certificate_test.go @@ -0,0 +1,97 @@ +package resources_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/custom-resource/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + unstruct "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/static" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var update = flag.Bool("update", false, "update golden files") + +func testOwner() *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +// TestCertificateShape verifies the CertificateRequest's shape after mutations +// set spec fields (issuerRef, dnsNames) and metadata labels. The golden file +// catches regressions when the mutation logic or base object changes. +func TestCertificateShape(t *testing.T) { + owner := testOwner() + + res, err := static.NewBuilder(resources.BaseCertificateRequest(owner)). + WithMutation(unstruct.Mutation{ + Name: "certificate-spec", + Mutate: func(m *unstruct.Mutator) error { + m.EditContent(func(e *editors.UnstructuredContentEditor) error { + if err := e.SetNestedString("letsencrypt-prod", "spec", "issuerRef", "name"); err != nil { + return err + } + if err := e.SetNestedString("ClusterIssuer", "spec", "issuerRef", "kind"); err != nil { + return err + } + return e.SetNestedSlice( + []interface{}{owner.Name + ".example.com"}, + "spec", "dnsNames", + ) + }) + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("app", owner.Name) + return nil + }) + + return nil + }, + }). + Build() + require.NoError(t, err) + + golden.AssertYAML(t, "testdata/certificate.yaml", res, golden.Update(*update)) +} + +// TestCertificateBaseShape pins the bare base object before any mutations. +// This isolates baseline regressions from mutation regressions. +func TestCertificateBaseShape(t *testing.T) { + owner := testOwner() + base := resources.BaseCertificateRequest(owner) + + res, err := static.NewBuilder(base).Build() + require.NoError(t, err) + + golden.AssertYAML(t, "testdata/certificate-base.yaml", res, golden.Update(*update)) +} + +// Verify the unstructured object uses the expected GVK type metadata, since +// golden.AssertYAML relies on it for the apiVersion and kind fields. +func TestCertificateGVK(t *testing.T) { + owner := testOwner() + base := resources.BaseCertificateRequest(owner) + + require.Equal(t, resources.CertificateRequestGVK, base.GroupVersionKind()) + require.Equal(t, "my-app-cert", base.GetName()) + require.Equal(t, "default", base.GetNamespace()) +} + +// Verify the Previewer interface conformance for the unstructured static resource. +func TestCertificatePreviewObject(t *testing.T) { + owner := testOwner() + res, err := static.NewBuilder(resources.BaseCertificateRequest(owner)).Build() + require.NoError(t, err) + + obj, err := res.PreviewObject() + require.NoError(t, err) + require.IsType(t, &uns.Unstructured{}, obj) +} diff --git a/examples/custom-resource/resources/testdata/certificate-base.yaml b/examples/custom-resource/resources/testdata/certificate-base.yaml new file mode 100644 index 00000000..c750da67 --- /dev/null +++ b/examples/custom-resource/resources/testdata/certificate-base.yaml @@ -0,0 +1,5 @@ +apiVersion: cert.example.io/v1 +kind: CertificateRequest +metadata: + name: my-app-cert + namespace: default diff --git a/examples/custom-resource/resources/testdata/certificate.yaml b/examples/custom-resource/resources/testdata/certificate.yaml new file mode 100644 index 00000000..70c27228 --- /dev/null +++ b/examples/custom-resource/resources/testdata/certificate.yaml @@ -0,0 +1,13 @@ +apiVersion: cert.example.io/v1 +kind: CertificateRequest +metadata: + labels: + app: my-app + name: my-app-cert + namespace: default +spec: + dnsNames: + - my-app.example.com + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod diff --git a/examples/daemonset-primitive/README.md b/examples/daemonset-primitive/README.md deleted file mode 100644 index 8c108d6d..00000000 --- a/examples/daemonset-primitive/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# DaemonSet Primitive Example - -This example demonstrates the usage of the `daemonset` primitive within the operator component framework. It shows how -to manage a Kubernetes DaemonSet as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing a DaemonSet with basic metadata and spec. -- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the - `Mutator`. -- **Suspension**: Demonstrating the delete-on-suspend behavior unique to DaemonSets. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: sidecar injection, env vars, and version-based image updates. -- `resources/`: Contains the central `NewDaemonSetResource` factory that assembles all features using the - `daemonset.Builder`. -- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/daemonset-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components through multiple spec changes. -4. Print the resulting status conditions. diff --git a/examples/daemonset-primitive/app/controller.go b/examples/daemonset-primitive/app/controller.go deleted file mode 100644 index a44f9427..00000000 --- a/examples/daemonset-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the daemonset primitive. -package app - -import ( - "context" - - "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 - - // NewDaemonSetResource is a factory function to create the daemonset resource. - // This allows us to inject the resource construction logic. - NewDaemonSetResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the daemonset resource for this owner. - dsResource, err := r.NewDaemonSetResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the daemonset. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(dsResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/daemonset-primitive/features/mutations.go b/examples/daemonset-primitive/features/mutations.go deleted file mode 100644 index f5f93b3f..00000000 --- a/examples/daemonset-primitive/features/mutations.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package features provides sample features for the daemonset primitive. -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/daemonset" - corev1 "k8s.io/api/core/v1" -) - -// TracingFeature adds a Jaeger sidecar to the daemonset. -func TracingFeature(enabled bool) daemonset.Mutation { - return daemonset.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *daemonset.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "JAEGER_AGENT_HOST", - Value: "localhost", - }) - - return nil - }, - } -} - -// MetricsFeature adds an exporter sidecar and some annotations. -func MetricsFeature(enabled bool, port int) daemonset.Mutation { - return daemonset.Mutation{ - Name: "Metrics", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *daemonset.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "prometheus-exporter", - Image: "prom/node-exporter:v1.3.1", - }) - - m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("prometheus.io/scrape", "true") - meta.EnsureAnnotation("prometheus.io/port", fmt.Sprintf("%d", port)) - return nil - }) - - return nil - }, - } -} - -// VersionFeature sets the image version and a label. -func VersionFeature(version string) daemonset.Mutation { - return daemonset.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *daemonset.Mutator) error { - m.EditContainers(selectors.ContainerNamed("agent"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-agent:%s", version) - return nil - }) - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - - return nil - }, - } -} diff --git a/examples/daemonset-primitive/main.go b/examples/daemonset-primitive/main.go deleted file mode 100644 index 67f06ce3..00000000 --- a/examples/daemonset-primitive/main.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package main is the entry point for the daemonset 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/daemonset-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/daemonset-primitive/resources" - appsv1 "k8s.io/api/apps/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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := appsv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add apps/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the daemonset resource factory. - NewDaemonSetResource: resources.NewDaemonSetResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: true, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/daemonset-primitive/resources/daemonset.go b/examples/daemonset-primitive/resources/daemonset.go deleted file mode 100644 index a205a1a4..00000000 --- a/examples/daemonset-primitive/resources/daemonset.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package resources provides resource implementations for the daemonset primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/daemonset-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/daemonset-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/daemonset" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -// NewDaemonSetResource constructs a daemonset primitive resource with all the features. -func NewDaemonSetResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base daemonset object. - base := &appsv1.DaemonSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-daemonset", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: appsv1.DaemonSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": owner.Name, - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "agent", - Image: "my-agent:latest", // Will be overwritten by VersionFeature - }, - }, - }, - }, - }, - } - - // 2. Initialize the daemonset builder. - builder := daemonset.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionFeature(owner.Spec.Version)) - builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics, 9090)) - - // 4. Data extraction (optional). - builder.WithDataExtractor(func(d appsv1.DaemonSet) error { - fmt.Printf("Reconciling desired DaemonSet object: %s/%s\n", d.Namespace, d.Name) - - // Print the complete daemonset resource object as yaml - y, err := yaml.Marshal(d) - if err != nil { - return fmt.Errorf("failed to marshal daemonset to yaml: %w", err) - } - fmt.Printf("Complete DaemonSet Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/deployment-primitive/README.md b/examples/deployment-primitive/README.md deleted file mode 100644 index a9b0f27f..00000000 --- a/examples/deployment-primitive/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Deployment Primitive Example - -This example demonstrates the usage of the `deployment` primitive within the operator component framework. It shows how -to manage a Kubernetes Deployment as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing a Deployment with basic metadata and spec. -- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the - `Mutator`. -- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health - assessment during rollouts (`GraceStatus`). -- **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: sidecar injection, env vars, and version-based image updates. - - `status.go`: implementation of custom handlers for convergence, grace, and suspension. -- `resources/`: Contains the central `NewDeploymentResource` factory that assembles all features using the - `deployment.Builder`. -- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/deployment-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components. -4. Print the resulting status conditions. diff --git a/examples/deployment-primitive/app/controller.go b/examples/deployment-primitive/app/controller.go deleted file mode 100644 index 84cd39f9..00000000 --- a/examples/deployment-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the deployment primitive. -package app - -import ( - "context" - - "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 - - // NewDeploymentResource is a factory function to create the deployment resource. - // This allows us to inject the resource construction logic. - NewDeploymentResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the deployment resource for this owner. - deployResource, err := r.NewDeploymentResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the deployment. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(deployResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/deployment-primitive/features/mutations.go b/examples/deployment-primitive/features/mutations.go deleted file mode 100644 index 23343ed9..00000000 --- a/examples/deployment-primitive/features/mutations.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package features provides example feature mutations for the deployment primitive. -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" - corev1 "k8s.io/api/core/v1" -) - -// TracingFeature adds a Jaeger sidecar to the deployment. -func TracingFeature(enabled bool) deployment.Mutation { - return deployment.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *deployment.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "JAEGER_AGENT_HOST", - Value: "localhost", - }) - - return nil - }, - } -} - -// MetricsFeature adds an exporter sidecar and some annotations. -func MetricsFeature(enabled bool, port int) deployment.Mutation { - return deployment.Mutation{ - Name: "Metrics", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *deployment.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "prometheus-exporter", - Image: "prom/node-exporter:v1.3.1", - }) - - m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("prometheus.io/scrape", "true") - meta.EnsureAnnotation("prometheus.io/port", fmt.Sprintf("%d", port)) - return nil - }) - - return nil - }, - } -} - -// VersionFeature sets the image version and a label. -func VersionFeature(version string) deployment.Mutation { - return deployment.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *deployment.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-app:%s", version) - return nil - }) - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - - return nil - }, - } -} diff --git a/examples/deployment-primitive/features/status.go b/examples/deployment-primitive/features/status.go deleted file mode 100644 index 7e5aa3a0..00000000 --- a/examples/deployment-primitive/features/status.go +++ /dev/null @@ -1,66 +0,0 @@ -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" - appsv1 "k8s.io/api/apps/v1" -) - -// CustomConvergeStatus demonstrates a custom handler for deployment readiness. -func CustomConvergeStatus() func(concepts.ConvergingOperation, *appsv1.Deployment) (concepts.AliveStatusWithReason, error) { - return func(op concepts.ConvergingOperation, d *appsv1.Deployment) (concepts.AliveStatusWithReason, error) { - // Use the default logic but add a custom reason or additional checks. - status, err := deployment.DefaultConvergingStatusHandler(op, d) - if err != nil { - return status, err - } - - if status.Status == concepts.AliveConvergingStatusHealthy { - status.Reason = "Application is fully operational and healthy" - } else { - status.Reason = fmt.Sprintf("Application is warming up: %s", status.Reason) - } - - return status, nil - } -} - -// CustomGraceStatus demonstrates a custom handler for health when not ready. -func CustomGraceStatus() func(*appsv1.Deployment) (concepts.GraceStatusWithReason, error) { - return func(d *appsv1.Deployment) (concepts.GraceStatusWithReason, error) { - // Example: If it's a critical component, we might want to report Down - // even if some replicas are up but not enough. - if d.Status.ReadyReplicas < 2 { - return concepts.GraceStatusWithReason{ - Status: concepts.GraceStatusDown, - Reason: "At least 2 replicas are required for minimal service", - }, nil - } - - return deployment.DefaultGraceStatusHandler(d) - } -} - -// CustomSuspendMutation demonstrates a custom mutation when suspended. -func CustomSuspendMutation() func(*deployment.Mutator) error { - return func(m *deployment.Mutator) error { - // Default is scaling to 0. - if err := deployment.DefaultSuspendMutationHandler(m); err != nil { - return err - } - - // Additionally, mark the deployment as suspended via an annotation. - // Note: mutators operate on a freshly-built desired object each reconcile, - // not the live server state. Stateful comparisons (e.g., "only set this if - // it doesn't already exist") won't work here since the object is always new. - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("example.io/suspended", "true") - return nil - }) - - return nil - } -} diff --git a/examples/deployment-primitive/main.go b/examples/deployment-primitive/main.go deleted file mode 100644 index feb55c84..00000000 --- a/examples/deployment-primitive/main.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package main is the entry point for the deployment 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/deployment-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/deployment-primitive/resources" - appsv1 "k8s.io/api/apps/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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := appsv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add apps/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the deployment resource factory. - NewDeploymentResource: resources.NewDeploymentResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: true, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/deployment-primitive/resources/deployment.go b/examples/deployment-primitive/resources/deployment.go deleted file mode 100644 index 17f73094..00000000 --- a/examples/deployment-primitive/resources/deployment.go +++ /dev/null @@ -1,83 +0,0 @@ -// Package resources provides resource implementations for the deployment primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/deployment-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/deployment-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -// NewDeploymentResource constructs a deployment primitive resource with all the features. -func NewDeploymentResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base deployment object. - base := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-deployment", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": owner.Name, - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "my-app:latest", // Will be overwritten by VersionFeature - }, - }, - }, - }, - }, - } - - // 2. Initialize the deployment builder. - builder := deployment.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionFeature(owner.Spec.Version)) - builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics, 9090)) - - // 4. Configure custom status handlers. - builder.WithCustomConvergeStatus(features.CustomConvergeStatus()) - builder.WithCustomGraceStatus(features.CustomGraceStatus()) - - // 5. Configure custom suspension logic. - builder.WithCustomSuspendMutation(features.CustomSuspendMutation()) - - // 6. Data extraction (optional). - builder.WithDataExtractor(func(d appsv1.Deployment) error { - fmt.Printf("Reconciling deployment: %s, ready replicas: %d\n", d.Name, d.Status.ReadyReplicas) - - // Print the complete deployment resource object as yaml - y, err := yaml.Marshal(d) - if err != nil { - return fmt.Errorf("failed to marshal deployment to yaml: %w", err) - } - fmt.Printf("Complete Deployment Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 7. Build the final resource. - return builder.Build() -} diff --git a/examples/extraction-and-guards/README.md b/examples/extraction-and-guards/README.md new file mode 100644 index 00000000..a5763734 --- /dev/null +++ b/examples/extraction-and-guards/README.md @@ -0,0 +1,25 @@ +# Data Extraction and Guards + +This example demonstrates how to use **data extraction** from one resource to feed a **guard** on a subsequent resource +within the same component. + +## What it shows + +- **Data extraction**: The ConfigMap resource registers a `WithDataExtractor` that captures the `db-host` value into a + shared pointer after reconciliation. +- **Guard**: The Secret resource registers a `WithGuard` that checks whether the extracted `db-host` is non-empty. If it + is empty, the guard returns `Blocked` and the Secret (and any resources registered after it) are skipped. +- **Registration order matters**: The ConfigMap is registered before the Secret. Guards can only read data extracted by + preceding resources. + +## Reconciliation steps + +1. Normal reconciliation: the ConfigMap is created, `db-host` is extracted, the guard unblocks, and the Secret is + created. +2. Steady-state: both resources reconcile normally. + +## Running + +```bash +go run ./examples/extraction-and-guards/. +``` diff --git a/examples/extraction-and-guards/app/controller.go b/examples/extraction-and-guards/app/controller.go new file mode 100644 index 00000000..8f00c9ec --- /dev/null +++ b/examples/extraction-and-guards/app/controller.go @@ -0,0 +1,64 @@ +// Package app provides a sample controller demonstrating data extraction and guards. +package app + +import ( + "context" + + "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" +) + +// Controller reconciles an ExampleApp by managing a ConfigMap and a Secret +// within a single component. The ConfigMap exposes data via extraction, and +// the Secret is guarded until that data is available. +type Controller struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewConfigMapResource builds the ConfigMap and wires the data extractor. + // The extractor writes to dbHost so the Secret guard can read it. + NewConfigMapResource func(owner *ExampleApp, dbHost *string) (component.Resource, error) + + // NewSecretResource builds the Secret with a guard that reads dbHost. + NewSecretResource func(owner *ExampleApp, dbHost *string) (component.Resource, error) +} + +// Reconcile builds and reconciles a component where the ConfigMap is registered +// before the Secret. Registration order matters: the guard on the Secret can +// only read data extracted by a preceding resource. +func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error { + // Shared state: the ConfigMap extractor writes here, the Secret guard reads it. + var dbHost string + + cmResource, err := r.NewConfigMapResource(owner, &dbHost) + if err != nil { + return err + } + + secretResource, err := r.NewSecretResource(owner, &dbHost) + if err != nil { + return err + } + + comp, err := component.NewComponentBuilder(). + WithName("database"). + WithConditionType("DatabaseReady"). + WithResource(cmResource, component.ResourceOptions{}). + WithResource(secretResource, component.ResourceOptions{}). + Build() + if err != nil { + return err + } + + return comp.Reconcile(ctx, component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + }) +} diff --git a/examples/hpa-primitive/app/owner.go b/examples/extraction-and-guards/app/owner.go similarity index 100% rename from examples/hpa-primitive/app/owner.go rename to examples/extraction-and-guards/app/owner.go diff --git a/examples/extraction-and-guards/main.go b/examples/extraction-and-guards/main.go new file mode 100644 index 00000000..5caa5a03 --- /dev/null +++ b/examples/extraction-and-guards/main.go @@ -0,0 +1,92 @@ +// Package main demonstrates data extraction and guard-based resource ordering. +// +// A single component manages a ConfigMap and a Secret. The ConfigMap's data +// extractor captures a value that the Secret's guard checks before allowing +// reconciliation to proceed. +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/extraction-and-guards/app" + "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/record" +) + +func main() { + scheme := runtime.NewScheme() + mustAddToScheme(scheme, app.AddToScheme) + mustAddToScheme(scheme, corev1.AddToScheme) + + fakeClient := sharedapp.NewFakeClient(scheme, []sharedapp.RESTMapperEntry{ + {GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, Scope: meta.RESTScopeNamespace}, + {GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, Scope: meta.RESTScopeNamespace}, + {GVK: sharedapp.GroupVersion.WithKind("ExampleApp"), Scope: meta.RESTScopeNamespace}, + }) + + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + + ctx := context.Background() + if err := fakeClient.Create(ctx, owner); err != nil { + exit("failed to create owner: %v", err) + } + + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.Controller{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example", + OperatorConditionsGauge: gauge, + }, + NewConfigMapResource: resources.NewConfigMapResource, + NewSecretResource: resources.NewSecretResource, + } + + // Step 1: Normal reconciliation. The ConfigMap is created first, its data + // extractor captures db-host, and the Secret guard unblocks. + fmt.Println("--- Step 1: Normal reconciliation ---") + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + // Step 2: Reconcile again to show steady-state behavior. + fmt.Println("\n--- Step 2: Steady-state reconciliation ---") + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + fmt.Println("\nDone.") +} + +func printConditions(owner *app.ExampleApp) { + for _, c := range owner.Status.Conditions { + fmt.Printf(" Condition: %s Status: %s Reason: %s\n", c.Type, c.Status, c.Reason) + } +} + +func mustAddToScheme(scheme *runtime.Scheme, fn func(*runtime.Scheme) error) { + if err := fn(scheme); err != nil { + exit("failed to add to scheme: %v", err) + } +} + +func exit(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/examples/extraction-and-guards/resources/configmap.go b/examples/extraction-and-guards/resources/configmap.go new file mode 100644 index 00000000..89fa9693 --- /dev/null +++ b/examples/extraction-and-guards/resources/configmap.go @@ -0,0 +1,43 @@ +// Package resources provides resource factories for the extraction-and-guards example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BaseConfigMap returns the desired-state ConfigMap representing database +// connection config. +func BaseConfigMap(owner *app.ExampleApp) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-db-config", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + Data: map[string]string{ + "db-host": "postgres.default.svc", + "db-port": "5432", + }, + } +} + +// NewConfigMapResource constructs a ConfigMap for database config. After +// reconciliation, the data extractor captures the db-host value into the +// provided pointer so downstream resources can use it. +func NewConfigMapResource(owner *app.ExampleApp, dbHost *string) (component.Resource, error) { + builder := configmap.NewBuilder(BaseConfigMap(owner)) + + builder.WithDataExtractor(func(cm corev1.ConfigMap) error { + *dbHost = cm.Data["db-host"] + fmt.Printf(" Extracted db-host: %q\n", *dbHost) + return nil + }) + + return builder.Build() +} diff --git a/examples/extraction-and-guards/resources/configmap_test.go b/examples/extraction-and-guards/resources/configmap_test.go new file mode 100644 index 00000000..4d293e5f --- /dev/null +++ b/examples/extraction-and-guards/resources/configmap_test.go @@ -0,0 +1,40 @@ +package resources_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var update = flag.Bool("update", false, "update golden files") + +func testOwner() *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +// TestConfigMapShape pins the database config ConfigMap's baseline shape. +// If the base object changes (e.g. new keys added or defaults changed), the +// golden file catches it so the change is reviewed explicitly. +func TestConfigMapShape(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + owner := testOwner() + res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).Build() + require.NoError(t, err) + + golden.AssertYAML(t, "testdata/configmap.yaml", res, + golden.WithScheme(scheme), golden.Update(*update)) +} diff --git a/examples/extraction-and-guards/resources/secret.go b/examples/extraction-and-guards/resources/secret.go new file mode 100644 index 00000000..d3984975 --- /dev/null +++ b/examples/extraction-and-guards/resources/secret.go @@ -0,0 +1,51 @@ +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BaseSecret returns the desired-state Secret representing database credentials. +func BaseSecret(owner *app.ExampleApp) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-db-credentials", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + StringData: map[string]string{ + "username": "app-user", + "password": "changeme", + }, + } +} + +// NewSecretResource constructs a Secret for database credentials. A guard +// blocks this resource until the db-host value has been extracted from the +// preceding ConfigMap. +func NewSecretResource(owner *app.ExampleApp, dbHost *string) (component.Resource, error) { + builder := secret.NewBuilder(BaseSecret(owner)) + + builder.WithGuard(func(_ corev1.Secret) (concepts.GuardStatusWithReason, error) { + if *dbHost == "" { + fmt.Println(" Guard: blocked, waiting for db-host from ConfigMap") + return concepts.GuardStatusWithReason{ + Status: concepts.GuardStatusBlocked, + Reason: "waiting for db-host to be extracted from ConfigMap", + }, nil + } + + fmt.Printf(" Guard: unblocked, db-host is %q\n", *dbHost) + return concepts.GuardStatusWithReason{ + Status: concepts.GuardStatusUnblocked, + }, nil + }) + + return builder.Build() +} diff --git a/examples/extraction-and-guards/resources/secret_test.go b/examples/extraction-and-guards/resources/secret_test.go new file mode 100644 index 00000000..74017c16 --- /dev/null +++ b/examples/extraction-and-guards/resources/secret_test.go @@ -0,0 +1,27 @@ +package resources_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// TestSecretShape pins the database credentials Secret's baseline shape. +// The guard is not exercised here; this test only verifies the resource's +// desired state before reconciliation. +func TestSecretShape(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + owner := testOwner() + res, err := secret.NewBuilder(resources.BaseSecret(owner)).Build() + require.NoError(t, err) + + golden.AssertYAML(t, "testdata/secret.yaml", res, + golden.WithScheme(scheme), golden.Update(*update)) +} diff --git a/examples/extraction-and-guards/resources/testdata/configmap.yaml b/examples/extraction-and-guards/resources/testdata/configmap.yaml new file mode 100644 index 00000000..97e8dfc6 --- /dev/null +++ b/examples/extraction-and-guards/resources/testdata/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +data: + db-host: postgres.default.svc + db-port: "5432" +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-db-config + namespace: default diff --git a/examples/extraction-and-guards/resources/testdata/secret.yaml b/examples/extraction-and-guards/resources/testdata/secret.yaml new file mode 100644 index 00000000..1f8900ae --- /dev/null +++ b/examples/extraction-and-guards/resources/testdata/secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +data: + password: Y2hhbmdlbWU= + username: YXBwLXVzZXI= +kind: Secret +metadata: + labels: + app: my-app + name: my-app-db-credentials + namespace: default diff --git a/examples/grace-inconsistency/README.md b/examples/grace-inconsistency/README.md new file mode 100644 index 00000000..c29675bc --- /dev/null +++ b/examples/grace-inconsistency/README.md @@ -0,0 +1,32 @@ +# Grace Inconsistency Suppression + +This example demonstrates how to suppress the **grace inconsistency warning** using `SuppressGraceInconsistencyWarning` +in `ResourceOptions`. + +## What it shows + +- **Custom grace handler**: The Deployment overrides `WithCustomGraceStatus` to always return `GraceStatusHealthy`, + regardless of replica readiness. This is intentional for a soft-dependency resource like a monitoring sidecar. +- **Inconsistency**: The convergence handler reports non-healthy (0 ready replicas), while the grace handler reports + healthy. By default the framework logs a warning about this mismatch. +- **Suppression**: `NewResourceOptionsBuilder().SuppressGraceInconsistencyWarning().Build()` tells the framework the + inconsistency is deliberate, silencing the warning. +- **Grace period**: The component uses `WithGracePeriod(5 * time.Second)` to set the window during which the grace + handler is consulted. + +## When to use this + +Use `SuppressGraceInconsistencyWarning` when a custom grace handler deliberately reports a different health status than +the convergence handler. The typical case is a resource that is "nice to have" but should not block the component from +being considered healthy during its grace period. + +## Reconciliation steps + +1. Initial reconciliation with 0 ready replicas. Convergence says non-healthy, grace says healthy, no warning logged. +2. Steady-state reconciliation. + +## Running + +```bash +go run ./examples/grace-inconsistency/. +``` diff --git a/examples/grace-inconsistency/app/controller.go b/examples/grace-inconsistency/app/controller.go new file mode 100644 index 00000000..42c8ebb7 --- /dev/null +++ b/examples/grace-inconsistency/app/controller.go @@ -0,0 +1,62 @@ +// Package app provides a sample controller demonstrating grace inconsistency suppression. +package app + +import ( + "context" + "time" + + "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" +) + +// Controller reconciles an ExampleApp with a Deployment that has a custom grace +// handler intentionally returning Healthy while the convergence handler may +// report non-healthy. The inconsistency warning is suppressed via ResourceOptions. +type Controller struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + NewDeploymentResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile builds and reconciles a component with grace period and +// inconsistency suppression. +func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error { + deployResource, err := r.NewDeploymentResource(owner) + if err != nil { + return err + } + + // SuppressGraceInconsistencyWarning tells the framework not to log a + // warning when the custom grace handler reports Healthy while the + // convergence handler reports non-healthy. This is intentional: the + // deployment is a soft dependency and should not block the component. + opts, err := component.NewResourceOptionsBuilder(). + SuppressGraceInconsistencyWarning(). + Build() + if err != nil { + return err + } + + comp, err := component.NewComponentBuilder(). + WithName("monitoring"). + WithConditionType("MonitoringReady"). + WithResource(deployResource, opts). + WithGracePeriod(5 * time.Second). + Build() + if err != nil { + return err + } + + return comp.Reconcile(ctx, component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + }) +} diff --git a/examples/ingress-primitive/app/owner.go b/examples/grace-inconsistency/app/owner.go similarity index 100% rename from examples/ingress-primitive/app/owner.go rename to examples/grace-inconsistency/app/owner.go diff --git a/examples/grace-inconsistency/main.go b/examples/grace-inconsistency/main.go new file mode 100644 index 00000000..5ae34643 --- /dev/null +++ b/examples/grace-inconsistency/main.go @@ -0,0 +1,93 @@ +// Package main demonstrates suppressing the grace inconsistency warning. +// +// When a custom grace handler intentionally reports Healthy while the +// convergence handler reports non-healthy, the framework logs a warning by +// default. SuppressGraceInconsistencyWarning disables that warning for +// resources where the inconsistency is a deliberate design choice. +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/grace-inconsistency/app" + "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/record" +) + +func main() { + scheme := runtime.NewScheme() + mustAddToScheme(scheme, app.AddToScheme) + mustAddToScheme(scheme, appsv1.AddToScheme) + + fakeClient := sharedapp.NewFakeClient(scheme, []sharedapp.RESTMapperEntry{ + {GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, Scope: meta.RESTScopeNamespace}, + {GVK: sharedapp.GroupVersion.WithKind("ExampleApp"), Scope: meta.RESTScopeNamespace}, + }) + + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + + ctx := context.Background() + if err := fakeClient.Create(ctx, owner); err != nil { + exit("failed to create owner: %v", err) + } + + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.Controller{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example", + OperatorConditionsGauge: gauge, + }, + NewDeploymentResource: resources.NewDeploymentResource, + } + + // The deployment has 0 ready replicas (fake client default), so the + // convergence handler reports non-healthy. The custom grace handler + // intentionally returns Healthy. Without suppression this would log a + // warning; with SuppressGraceInconsistencyWarning it is silent. + fmt.Println("--- Step 1: Initial reconciliation (0 ready replicas) ---") + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + // Reconcile again to show steady-state. + fmt.Println("\n--- Step 2: Steady-state reconciliation ---") + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + printConditions(owner) + + fmt.Println("\nDone.") +} + +func printConditions(owner *app.ExampleApp) { + for _, c := range owner.Status.Conditions { + fmt.Printf(" Condition: %s Status: %s Reason: %s\n", c.Type, c.Status, c.Reason) + } +} + +func mustAddToScheme(scheme *runtime.Scheme, fn func(*runtime.Scheme) error) { + if err := fn(scheme); err != nil { + exit("failed to add to scheme: %v", err) + } +} + +func exit(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/examples/grace-inconsistency/resources/deployment.go b/examples/grace-inconsistency/resources/deployment.go new file mode 100644 index 00000000..9d998464 --- /dev/null +++ b/examples/grace-inconsistency/resources/deployment.go @@ -0,0 +1,60 @@ +// Package resources provides resource factories for the grace-inconsistency example. +package resources + +import ( + "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BaseDeployment returns the desired-state Deployment for the monitoring sidecar. +func BaseDeployment(owner *app.ExampleApp) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-monitoring", + Namespace: owner.Namespace, + Labels: map[string]string{"app": owner.Name}, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": owner.Name, "role": "monitoring"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": owner.Name, "role": "monitoring"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "prometheus-exporter", + Image: "prom/node-exporter:v1.3.1", + }, + }, + }, + }, + }, + } +} + +// NewDeploymentResource constructs a Deployment with a custom grace handler +// that always returns Healthy, regardless of replica readiness. +// +// This is useful for soft-dependency resources like a monitoring sidecar: if it +// has not converged yet, the operator still considers the component healthy +// rather than blocking on it. +func NewDeploymentResource(owner *app.ExampleApp) (component.Resource, error) { + builder := deployment.NewBuilder(BaseDeployment(owner)) + + builder.WithCustomGraceStatus(func(_ *appsv1.Deployment) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusHealthy, + Reason: "monitoring is a soft dependency, always considered healthy", + }, nil + }) + + return builder.Build() +} diff --git a/examples/grace-inconsistency/resources/deployment_test.go b/examples/grace-inconsistency/resources/deployment_test.go new file mode 100644 index 00000000..691f48a3 --- /dev/null +++ b/examples/grace-inconsistency/resources/deployment_test.go @@ -0,0 +1,42 @@ +package resources_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var update = flag.Bool("update", false, "update golden files") + +func testOwner() *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +func testScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = appsv1.AddToScheme(s) + return s +} + +// TestDeploymentShape pins the monitoring Deployment's baseline. This resource +// has no mutations; changes to the base object surface as a golden file diff. +func TestDeploymentShape(t *testing.T) { + owner := testOwner() + res, err := deployment.NewBuilder(resources.BaseDeployment(owner)).Build() + require.NoError(t, err) + + golden.AssertYAML(t, "testdata/deployment.yaml", res, + golden.WithScheme(testScheme()), golden.Update(*update)) +} diff --git a/examples/grace-inconsistency/resources/testdata/deployment.yaml b/examples/grace-inconsistency/resources/testdata/deployment.yaml new file mode 100644 index 00000000..7be53598 --- /dev/null +++ b/examples/grace-inconsistency/resources/testdata/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-monitoring + namespace: default +spec: + selector: + matchLabels: + app: my-app + role: monitoring + strategy: {} + template: + metadata: + labels: + app: my-app + role: monitoring + spec: + containers: + - image: prom/node-exporter:v1.3.1 + name: prometheus-exporter + resources: {} diff --git a/examples/hpa-primitive/README.md b/examples/hpa-primitive/README.md deleted file mode 100644 index 2f22716f..00000000 --- a/examples/hpa-primitive/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# HPA Primitive Example - -This example demonstrates the usage of the `hpa` primitive within the operator component framework. It shows how to -manage a Kubernetes HorizontalPodAutoscaler as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing an HPA with a scale target ref, min/max replicas, and labels. -- **Feature Mutations**: Applying version-gated or conditional changes (CPU metrics, memory metrics, scaling behavior) - using the `Mutator`. -- **Operational Status**: Reporting HPA health based on `ScalingActive` and `AbleToScale` conditions. -- **Suspension (Delete)**: Demonstrating delete-on-suspend behavior — the HPA is removed during suspension to prevent it - from scaling the target back up. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: CPU metric, memory metric, and scale behavior feature mutations. -- `resources/`: Contains the central `NewHPAResource` factory that assembles all features using the `hpa.Builder`. -- `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/hpa-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components through multiple spec changes. -4. Print the resulting status conditions and HPA state. diff --git a/examples/hpa-primitive/app/controller.go b/examples/hpa-primitive/app/controller.go deleted file mode 100644 index 4d552058..00000000 --- a/examples/hpa-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the HPA primitive. -package app - -import ( - "context" - - "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 - - // NewHPAResource is a factory function to create the HPA resource. - // This allows us to inject the resource construction logic. - NewHPAResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the HPA resource for this owner. - hpaResource, err := r.NewHPAResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the HPA. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(hpaResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/hpa-primitive/features/mutations.go b/examples/hpa-primitive/features/mutations.go deleted file mode 100644 index ba38dcaf..00000000 --- a/examples/hpa-primitive/features/mutations.go +++ /dev/null @@ -1,83 +0,0 @@ -// Package features provides sample features for the HPA 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/hpa" - autoscalingv2 "k8s.io/api/autoscaling/v2" - corev1 "k8s.io/api/core/v1" - "k8s.io/utils/ptr" -) - -// CPUMetricFeature configures CPU-based autoscaling with the given utilization target. -func CPUMetricFeature(version string, targetUtilization int32) hpa.Mutation { - return hpa.Mutation{ - Name: "CPUMetric", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *hpa.Mutator) error { - m.EditHPASpec(func(e *editors.HPASpecEditor) error { - e.EnsureMetric(autoscalingv2.MetricSpec{ - Type: autoscalingv2.ResourceMetricSourceType, - Resource: &autoscalingv2.ResourceMetricSource{ - Name: corev1.ResourceCPU, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.UtilizationMetricType, - AverageUtilization: ptr.To(targetUtilization), - }, - }, - }) - return nil - }) - - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - - return nil - }, - } -} - -// MemoryMetricFeature adds memory-based autoscaling when enabled. -func MemoryMetricFeature(enabled bool, targetUtilization int32) hpa.Mutation { - return hpa.Mutation{ - Name: "MemoryMetric", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *hpa.Mutator) error { - m.EditHPASpec(func(e *editors.HPASpecEditor) error { - e.EnsureMetric(autoscalingv2.MetricSpec{ - Type: autoscalingv2.ResourceMetricSourceType, - Resource: &autoscalingv2.ResourceMetricSource{ - Name: corev1.ResourceMemory, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.UtilizationMetricType, - AverageUtilization: ptr.To(targetUtilization), - }, - }, - }) - return nil - }) - return nil - }, - } -} - -// ScaleBehaviorFeature configures conservative scale-down behavior. -func ScaleBehaviorFeature() hpa.Mutation { - return hpa.Mutation{ - Name: "ScaleBehavior", - Mutate: func(m *hpa.Mutator) error { - m.EditHPASpec(func(e *editors.HPASpecEditor) error { - e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ - ScaleDown: &autoscalingv2.HPAScalingRules{ - StabilizationWindowSeconds: ptr.To(int32(300)), - }, - }) - return nil - }) - return nil - }, - } -} diff --git a/examples/hpa-primitive/main.go b/examples/hpa-primitive/main.go deleted file mode 100644 index fe087c9b..00000000 --- a/examples/hpa-primitive/main.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package main is the entry point for the HPA 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/hpa-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/hpa-primitive/resources" - autoscalingv2 "k8s.io/api/autoscaling/v2" - "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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := autoscalingv2.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add autoscaling/v2 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: false, - EnableMetrics: true, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the HPA resource factory. - NewHPAResource: resources.NewHPAResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableMetrics: false, // Disable memory metric - Suspended: false, - }, - { - Version: "1.2.4", - EnableMetrics: false, - Suspended: true, // Suspend the app (HPA is deleted to prevent scaling interference) - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableMetrics, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/hpa-primitive/resources/hpa.go b/examples/hpa-primitive/resources/hpa.go deleted file mode 100644 index 101dd948..00000000 --- a/examples/hpa-primitive/resources/hpa.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package resources provides resource implementations for the HPA primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/hpa-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/hpa-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/hpa" - autoscalingv2 "k8s.io/api/autoscaling/v2" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" - "sigs.k8s.io/yaml" -) - -// NewHPAResource constructs an HPA primitive resource with all the features. -func NewHPAResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base HPA object. - base := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-hpa", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ - ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: owner.Name + "-deployment", - }, - MinReplicas: ptr.To(int32(2)), - MaxReplicas: 10, - }, - } - - // 2. Initialize the HPA builder. - builder := hpa.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.CPUMetricFeature(owner.Spec.Version, 70)) - builder.WithMutation(features.MemoryMetricFeature(owner.Spec.EnableMetrics, 80)) - builder.WithMutation(features.ScaleBehaviorFeature()) - - // 4. Configure data extraction. - builder.WithDataExtractor(func(h autoscalingv2.HorizontalPodAutoscaler) error { - fmt.Printf("HPA %s: min=%d, max=%d, metrics=%d\n", - h.Name, - derefInt32(h.Spec.MinReplicas, 1), - h.Spec.MaxReplicas, - len(h.Spec.Metrics), - ) - - y, err := yaml.Marshal(h) - if err != nil { - return fmt.Errorf("failed to marshal HPA to yaml: %w", err) - } - fmt.Printf("Complete HPA Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} - -func derefInt32(p *int32, defaultVal int32) int32 { - if p != nil { - return *p - } - return defaultVal -} diff --git a/examples/ingress-primitive/README.md b/examples/ingress-primitive/README.md deleted file mode 100644 index 2a09b6f5..00000000 --- a/examples/ingress-primitive/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Ingress Primitive Example - -This example demonstrates the usage of the `ingress` primitive within the operator component framework. It shows how to -manage a Kubernetes Ingress as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing an Ingress with rules, TLS, and ingress class. -- **Feature Mutations**: Applying conditional changes (TLS configuration, version annotations) using the `Mutator`. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: version annotation and TLS configuration mutations. -- `resources/`: Contains the central `NewIngressResource` factory that assembles all features using the - `ingress.Builder`. -- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/ingress-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components through several spec changes (version upgrade, TLS toggle, suspension). -4. Print the resulting status conditions and Ingress resource state. diff --git a/examples/ingress-primitive/app/controller.go b/examples/ingress-primitive/app/controller.go deleted file mode 100644 index c3c53c96..00000000 --- a/examples/ingress-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the ingress primitive. -package app - -import ( - "context" - - "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 - - // NewIngressResource is a factory function to create the ingress resource. - // This allows us to inject the resource construction logic. - NewIngressResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the ingress resource for this owner. - ingressResource, err := r.NewIngressResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the ingress. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(ingressResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/ingress-primitive/features/mutations.go b/examples/ingress-primitive/features/mutations.go deleted file mode 100644 index 530d1ba3..00000000 --- a/examples/ingress-primitive/features/mutations.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package features provides modular feature mutations for the ingress 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/ingress" - networkingv1 "k8s.io/api/networking/v1" -) - -// VersionAnnotation sets a version annotation on the Ingress metadata. -func VersionAnnotation(version string) ingress.Mutation { - return ingress.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *ingress.Mutator) error { - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// TLSFeature adds a TLS entry when enabled. -func TLSFeature(enabled bool, appName string) ingress.Mutation { - return ingress.Mutation{ - Name: "TLS", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *ingress.Mutator) error { - m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { - e.EnsureTLS(networkingv1.IngressTLS{ - Hosts: []string{appName + ".example.com"}, - SecretName: appName + "-tls", - }) - return nil - }) - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("cert-manager.io/cluster-issuer", "letsencrypt-prod") - return nil - }) - return nil - }, - } -} diff --git a/examples/ingress-primitive/main.go b/examples/ingress-primitive/main.go deleted file mode 100644 index bb35a72c..00000000 --- a/examples/ingress-primitive/main.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package main is the entry point for the ingress 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/ingress-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/resources" - networkingv1 "k8s.io/api/networking/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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := networkingv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add networking/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, // Used as TLS toggle in this example - EnableMetrics: false, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the ingress resource factory. - NewIngressResource: resources.NewIngressResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, // TLS enabled - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable TLS - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, TLS=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/ingress-primitive/resources/ingress.go b/examples/ingress-primitive/resources/ingress.go deleted file mode 100644 index d0bc8e53..00000000 --- a/examples/ingress-primitive/resources/ingress.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package resources provides resource implementations for the ingress primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/ingress" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" - "sigs.k8s.io/yaml" -) - -// NewIngressResource constructs an ingress primitive resource with all the features. -func NewIngressResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base ingress object. - base := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-ingress", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("nginx"), - Rules: []networkingv1.IngressRule{ - { - Host: owner.Name + ".example.com", - IngressRuleValue: networkingv1.IngressRuleValue{ - HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - { - Path: "/", - PathType: ptr.To(networkingv1.PathTypePrefix), - Backend: networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: owner.Name + "-svc", - Port: networkingv1.ServiceBackendPort{Number: 80}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - - // 2. Initialize the ingress builder. - builder := ingress.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionAnnotation(owner.Spec.Version)) - builder.WithMutation(features.TLSFeature(owner.Spec.EnableTracing, owner.Name)) - - // 4. Data extraction. - builder.WithDataExtractor(func(ing networkingv1.Ingress) error { - fmt.Printf("Reconciling ingress: %s\n", ing.Name) - - y, err := yaml.Marshal(ing) - if err != nil { - return fmt.Errorf("failed to marshal ingress to yaml: %w", err) - } - fmt.Printf("Complete Ingress Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/job-primitive/README.md b/examples/job-primitive/README.md deleted file mode 100644 index a9532c98..00000000 --- a/examples/job-primitive/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Job Primitive Example - -This example demonstrates the usage of the `job` primitive within the operator component framework. It shows how to -manage a Kubernetes Job as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing a Job with basic metadata, spec, and restart policy. -- **Feature Mutations**: Applying version-gated or conditional changes (env vars, image version, retry policies) using - the `Mutator`. -- **Custom Status Handlers**: Overriding the default `ConvergingStatus` interface using the `WithCustomConvergeStatus` - builder option. -- **Suspension**: Demonstrating how Jobs are suspended (deleted by default) when the component is suspended. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: tracing env vars, retry policies, and version-based image updates. - - `status.go`: implementation of a custom handler for completion status. -- `resources/`: Contains the central `NewJobResource` factory that assembles all features using the `job.Builder`. -- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/job-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components through multiple spec changes. -4. Print the resulting status conditions after each reconciliation step. diff --git a/examples/job-primitive/app/controller.go b/examples/job-primitive/app/controller.go deleted file mode 100644 index 52262e7c..00000000 --- a/examples/job-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the job primitive. -package app - -import ( - "context" - - "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 - - // NewJobResource is a factory function to create the job resource. - // This allows us to inject the resource construction logic. - NewJobResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the job resource for this owner. - jobResource, err := r.NewJobResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the job. - comp, err := component.NewComponentBuilder(). - WithName("example-migration"). - WithConditionType("MigrationReady"). - WithResource(jobResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/job-primitive/features/mutations.go b/examples/job-primitive/features/mutations.go deleted file mode 100644 index 9fe5352d..00000000 --- a/examples/job-primitive/features/mutations.go +++ /dev/null @@ -1,70 +0,0 @@ -// Package features provides sample features for the job primitive. -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" - corev1 "k8s.io/api/core/v1" -) - -// TracingFeature adds tracing environment variables to the job containers. -func TracingFeature(enabled bool) job.Mutation { - return job.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *job.Mutator) error { - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "OTEL_EXPORTER_OTLP_ENDPOINT", - Value: "http://otel-collector:4317", - }) - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "OTEL_TRACES_SAMPLER", - Value: "always_on", - }) - - return nil - }, - } -} - -// RetryPolicyFeature configures the job's retry behavior. -func RetryPolicyFeature(version string) job.Mutation { - return job.Mutation{ - Name: "RetryPolicy", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *job.Mutator) error { - m.EditJobSpec(func(e *editors.JobSpecEditor) error { - e.SetBackoffLimit(3) - e.SetActiveDeadlineSeconds(600) - return nil - }) - - return nil - }, - } -} - -// VersionFeature sets the image version and a label. -func VersionFeature(version string) job.Mutation { - return job.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *job.Mutator) error { - m.EditContainers(selectors.ContainerNamed("migrate"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-app-migration:%s", version) - return nil - }) - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - - return nil - }, - } -} diff --git a/examples/job-primitive/features/status.go b/examples/job-primitive/features/status.go deleted file mode 100644 index ebdf6fc8..00000000 --- a/examples/job-primitive/features/status.go +++ /dev/null @@ -1,29 +0,0 @@ -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" - batchv1 "k8s.io/api/batch/v1" -) - -// CustomConvergeStatus demonstrates a custom handler for job completion status. -func CustomConvergeStatus() func(concepts.ConvergingOperation, *batchv1.Job) (concepts.CompletionStatusWithReason, error) { - return func(op concepts.ConvergingOperation, j *batchv1.Job) (concepts.CompletionStatusWithReason, error) { - // Use the default logic but add a custom reason or additional checks. - status, err := job.DefaultConvergingStatusHandler(op, j) - if err != nil { - return status, err - } - - switch status.Status { - case concepts.CompletionStatusCompleted: - status.Reason = "Migration completed successfully" - case concepts.CompletionStatusRunning: - status.Reason = fmt.Sprintf("Migration in progress: %s", status.Reason) - } - - return status, nil - } -} diff --git a/examples/job-primitive/main.go b/examples/job-primitive/main.go deleted file mode 100644 index 7d53ef9b..00000000 --- a/examples/job-primitive/main.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package main is the entry point for the job 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/job-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/job-primitive/resources" - batchv1 "k8s.io/api/batch/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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := batchv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add batch/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: false, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the job resource factory. - NewJobResource: resources.NewJobResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - Suspended: true, // Suspend the job - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/job-primitive/resources/job.go b/examples/job-primitive/resources/job.go deleted file mode 100644 index e1f85ada..00000000 --- a/examples/job-primitive/resources/job.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package resources provides resource implementations for the job primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/job-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/job-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/job" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -// NewJobResource constructs a job primitive resource with all the features. -func NewJobResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base Job object. - base := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-migration", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: batchv1.JobSpec{ - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, - Containers: []corev1.Container{ - { - Name: "migrate", - Image: "my-app-migration:latest", // Will be overwritten by VersionFeature - }, - }, - }, - }, - }, - } - - // 2. Initialize the job builder. - builder := job.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionFeature(owner.Spec.Version)) - builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - builder.WithMutation(features.RetryPolicyFeature(owner.Spec.Version)) - - // 4. Configure custom status handler. - builder.WithCustomConvergeStatus(features.CustomConvergeStatus()) - - // 5. Data extraction (optional). - builder.WithDataExtractor(func(j batchv1.Job) error { - fmt.Printf("Reconciling job: %s, active: %d, succeeded: %d, failed: %d\n", - j.Name, j.Status.Active, j.Status.Succeeded, j.Status.Failed) - - // Print the complete job resource object as yaml - y, err := yaml.Marshal(j) - if err != nil { - return fmt.Errorf("failed to marshal job to yaml: %w", err) - } - fmt.Printf("Complete Job Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 6. Build the final resource. - return builder.Build() -} diff --git a/examples/mutations-and-gating/README.md b/examples/mutations-and-gating/README.md new file mode 100644 index 00000000..0648fe35 --- /dev/null +++ b/examples/mutations-and-gating/README.md @@ -0,0 +1,40 @@ +# Mutations and Gating + +This example demonstrates how to use **version-gated** and **boolean-gated** mutations within a single component that +manages two resources: a Deployment and a ConfigMap. + +## What it shows + +- **Baseline as latest version**: The base Deployment represents the v2 layout (container named "app" with HTTP and + health ports). This follows the guideline that the baseline always reflects the latest desired state. +- **Version-gated backward compat mutation**: `BackwardCompatV1Container` activates for versions `< 2.0.0` and rolls the + baseline back to the v1 layout (container named "server", HTTP port only). Uses a `semver.Constraint` as a + `feature.VersionConstraint`. The `BackwardCompat` prefix makes the pattern immediately recognizable. +- **Boolean-gated mutation**: `TracingSidecarMutation` injects a Jaeger sidecar. It is gated via `.When(enabled)`, so + the sidecar is added only when tracing is on and removed when it is off. +- **Mutation ordering for container name stability**: `DebugLoggingMutation` targets `ContainerNamed("app")` and is + registered before `BackwardCompatV1Container`. This ensures it always sees the baseline name, even though the backward + compat mutation renames the container for older versions. The env var edit carries through the rename because the + backward compat mutation only overwrites `Name` and `Ports`, not `Env`. +- **Resource-level gating**: The ConfigMap is registered with + `NewResourceOptionsBuilder().When(owner.Spec.EnableMetrics).Build()`. When metrics are disabled, the framework deletes + the ConfigMap entirely rather than leaving an empty one behind. +- **Mutation-level gating within a resource**: `MetricsConfigMutation` on the ConfigMap is separately boolean-gated, + showing that gating can happen at both the resource and mutation levels. +- **Suspension**: The component supports suspension via `Suspend(owner.Spec.Suspended)`. + +## Reconciliation steps + +1. v1.9.0 with tracing and metrics: backward compat container layout ("server", single port). +2. Enable debug logging on v1: LOG_LEVEL=debug targets "app" (baseline name) and carries through the backward compat + rename. +3. Upgrade to v2.0.0: container switches to "app" with a health port added, debug logging still active. +4. Disable debug logging and tracing: sidecar removed, LOG_LEVEL removed from the Deployment. +5. Disable metrics: the entire ConfigMap is deleted via resource-level gating. +6. Suspend the application. + +## Running + +```bash +go run ./examples/mutations-and-gating/. +``` diff --git a/examples/mutations-and-gating/app/controller.go b/examples/mutations-and-gating/app/controller.go new file mode 100644 index 00000000..9e0f15b0 --- /dev/null +++ b/examples/mutations-and-gating/app/controller.go @@ -0,0 +1,65 @@ +// Package app provides a sample controller demonstrating mutations and gating. +package app + +import ( + "context" + + "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" +) + +// Controller reconciles an ExampleApp by managing a Deployment and a ConfigMap +// within a single component. The ConfigMap is gated on EnableMetrics, so it is +// created only when metrics are enabled and deleted when they are disabled. +type Controller struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + NewDeploymentResource func(*ExampleApp) (component.Resource, error) + NewConfigMapResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile builds and reconciles a single component containing both resources. +func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error { + deployResource, err := r.NewDeploymentResource(owner) + if err != nil { + return err + } + + cmResource, err := r.NewConfigMapResource(owner) + if err != nil { + return err + } + + // Gate the ConfigMap at the resource level: when metrics are disabled the + // framework deletes the ConfigMap and excludes it from health aggregation. + cmOpts, err := component.NewResourceOptionsBuilder(). + When(owner.Spec.EnableMetrics). + Build() + if err != nil { + return err + } + + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(deployResource, component.ResourceOptions{}). + WithResource(cmResource, cmOpts). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + return comp.Reconcile(ctx, component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + }) +} diff --git a/examples/job-primitive/app/owner.go b/examples/mutations-and-gating/app/owner.go similarity index 100% rename from examples/job-primitive/app/owner.go rename to examples/mutations-and-gating/app/owner.go diff --git a/examples/mutations-and-gating/features/constraint.go b/examples/mutations-and-gating/features/constraint.go new file mode 100644 index 00000000..3929bc91 --- /dev/null +++ b/examples/mutations-and-gating/features/constraint.go @@ -0,0 +1,30 @@ +// Package features provides example mutations for the mutations-and-gating example. +package features + +import ( + "github.com/Masterminds/semver/v3" + "github.com/sourcehawk/operator-component-framework/pkg/feature" +) + +// semverConstraint implements feature.VersionConstraint using Masterminds/semver. +type semverConstraint struct { + c *semver.Constraints +} + +// MustConstraint parses a semver constraint expression or panics. +func MustConstraint(expr string) feature.VersionConstraint { + c, err := semver.NewConstraint(expr) + if err != nil { + panic(err) + } + return &semverConstraint{c: c} +} + +// Enabled reports whether the constraint is satisfied for the given version. +func (s *semverConstraint) Enabled(version string) (bool, error) { + v, err := semver.NewVersion(version) + if err != nil { + return false, err + } + return s.c.Check(v), nil +} diff --git a/examples/mutations-and-gating/features/debug_logging.go b/examples/mutations-and-gating/features/debug_logging.go new file mode 100644 index 00000000..d58c3b24 --- /dev/null +++ b/examples/mutations-and-gating/features/debug_logging.go @@ -0,0 +1,28 @@ +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/mutation/selectors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + corev1 "k8s.io/api/core/v1" +) + +// DebugLoggingMutation sets LOG_LEVEL=debug on the application container when +// enabled. It targets [selectors.ContainerNamed] with the baseline name "app", +// so it must be registered before any backward compat mutation that renames +// the container. The edit carries through the rename because backward compat +// mutations only overwrite specific fields (Name, Ports), not the environment. +func DebugLoggingMutation(enabled bool) deployment.Mutation { + return deployment.Mutation{ + Name: "DebugLogging", + Feature: feature.NewVersionGate("any", nil).When(enabled), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"}) + return nil + }) + return nil + }, + } +} diff --git a/examples/mutations-and-gating/features/debug_logging_test.go b/examples/mutations-and-gating/features/debug_logging_test.go new file mode 100644 index 00000000..10acc1f3 --- /dev/null +++ b/examples/mutations-and-gating/features/debug_logging_test.go @@ -0,0 +1,51 @@ +package features_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestDebugLoggingMutation verifies that the mutation sets LOG_LEVEL=debug +// on the application container. +func TestDebugLoggingMutation(t *testing.T) { + base := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app"}, + }, + }, + }, + }, + } + + res, err := deployment.NewBuilder(base). + WithMutation(features.DebugLoggingMutation(true)). + Build() + require.NoError(t, err) + + obj, err := res.PreviewObject() + require.NoError(t, err) + + containers := obj.Spec.Template.Spec.Containers + require.Len(t, containers, 1) + assert.Equal(t, "app", containers[0].Name) + + found := false + for _, env := range containers[0].Env { + if env.Name == "LOG_LEVEL" { + assert.Equal(t, "debug", env.Value) + found = true + } + } + assert.True(t, found, "LOG_LEVEL not found on container app") +} diff --git a/examples/mutations-and-gating/features/legacy_container.go b/examples/mutations-and-gating/features/legacy_container.go new file mode 100644 index 00000000..9a435879 --- /dev/null +++ b/examples/mutations-and-gating/features/legacy_container.go @@ -0,0 +1,37 @@ +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/mutation/selectors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + corev1 "k8s.io/api/core/v1" +) + +// BackwardCompatV1Container rolls the v2 baseline back to the v1 container +// layout for versions before 2.0.0. In v1, the container was named "server" +// and only exposed the HTTP port. +// +// Backward compatibility mutations are named BackwardCompat so the +// pattern is immediately recognizable. When multiple backward compat mutations +// exist, register the newest first (closest to the baseline) and the oldest +// last. See the guidelines for details. +func BackwardCompatV1Container(version string) deployment.Mutation { + return deployment.Mutation{ + Name: "BackwardCompatV1Container", + Feature: feature.NewVersionGate( + version, + []feature.VersionConstraint{MustConstraint("< 2.0.0")}, + ), + Mutate: func(m *deployment.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.Raw().Name = "server" + ce.Raw().Ports = []corev1.ContainerPort{ + {Name: "http", ContainerPort: 8080}, + } + return nil + }) + return nil + }, + } +} diff --git a/examples/mutations-and-gating/features/legacy_container_test.go b/examples/mutations-and-gating/features/legacy_container_test.go new file mode 100644 index 00000000..1486b82a --- /dev/null +++ b/examples/mutations-and-gating/features/legacy_container_test.go @@ -0,0 +1,51 @@ +package features_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestBackwardCompatV1Container verifies that the mutation renames the +// container to "server" and drops the health port for pre-2.0 versions. +func TestBackwardCompatV1Container(t *testing.T) { + base := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Ports: []corev1.ContainerPort{ + {Name: "http", ContainerPort: 8080}, + {Name: "health", ContainerPort: 8081}, + }, + }, + }, + }, + }, + }, + } + + res, err := deployment.NewBuilder(base). + WithMutation(features.BackwardCompatV1Container("1.9.0")). + Build() + require.NoError(t, err) + + obj, err := res.PreviewObject() + require.NoError(t, err) + + containers := obj.Spec.Template.Spec.Containers + require.Len(t, containers, 1) + assert.Equal(t, "server", containers[0].Name) + assert.Len(t, containers[0].Ports, 1) + assert.Equal(t, "http", containers[0].Ports[0].Name) + assert.Equal(t, int32(8080), containers[0].Ports[0].ContainerPort) +} diff --git a/examples/mutations-and-gating/features/metrics_config.go b/examples/mutations-and-gating/features/metrics_config.go new file mode 100644 index 00000000..492c3095 --- /dev/null +++ b/examples/mutations-and-gating/features/metrics_config.go @@ -0,0 +1,24 @@ +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" +) + +// MetricsConfigMutation adds a Prometheus metrics section to app.yaml. +// It is boolean-gated on the enableMetrics flag. +func MetricsConfigMutation(version string, enableMetrics bool) configmap.Mutation { + return configmap.Mutation{ + Name: "metrics-config", + Feature: feature.NewVersionGate(version, nil).When(enableMetrics), + Mutate: func(m *configmap.Mutator) error { + m.MergeYAML("app.yaml", ` +metrics: + enabled: true + port: 9090 + path: /metrics +`) + return nil + }, + } +} diff --git a/examples/mutations-and-gating/features/metrics_config_test.go b/examples/mutations-and-gating/features/metrics_config_test.go new file mode 100644 index 00000000..b85ecac7 --- /dev/null +++ b/examples/mutations-and-gating/features/metrics_config_test.go @@ -0,0 +1,37 @@ +package features_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestMetricsConfigMutation verifies that the mutation merges a Prometheus +// metrics section into app.yaml. +func TestMetricsConfigMutation(t *testing.T) { + base := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string]string{ + "app.yaml": "server:\n port: 8080\n", + }, + } + + res, err := configmap.NewBuilder(base). + WithMutation(features.MetricsConfigMutation("1.0.0", true)). + Build() + require.NoError(t, err) + + obj, err := res.PreviewObject() + require.NoError(t, err) + + yaml := obj.Data["app.yaml"] + assert.Contains(t, yaml, "metrics:") + assert.Contains(t, yaml, "port: 9090") + assert.Contains(t, yaml, "path: /metrics") + assert.Contains(t, yaml, "server:") +} diff --git a/examples/mutations-and-gating/features/tracing_sidecar.go b/examples/mutations-and-gating/features/tracing_sidecar.go new file mode 100644 index 00000000..6b77ccd1 --- /dev/null +++ b/examples/mutations-and-gating/features/tracing_sidecar.go @@ -0,0 +1,29 @@ +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + corev1 "k8s.io/api/core/v1" +) + +// TracingSidecarMutation injects a Jaeger sidecar and sets JAEGER_AGENT_HOST on +// all containers. It is boolean-gated on the enableTracing flag. +func TracingSidecarMutation(enabled bool) deployment.Mutation { + return deployment.Mutation{ + Name: "Tracing", + Feature: feature.NewVersionGate("any", nil).When(enabled), + Mutate: func(m *deployment.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "jaeger-agent", + Image: "jaegertracing/jaeger-agent:1.28", + }) + + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "JAEGER_AGENT_HOST", + Value: "localhost", + }) + + return nil + }, + } +} diff --git a/examples/mutations-and-gating/features/tracing_sidecar_test.go b/examples/mutations-and-gating/features/tracing_sidecar_test.go new file mode 100644 index 00000000..30ab35ec --- /dev/null +++ b/examples/mutations-and-gating/features/tracing_sidecar_test.go @@ -0,0 +1,56 @@ +package features_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestTracingSidecarMutation verifies that the mutation injects a Jaeger +// sidecar and sets JAEGER_AGENT_HOST on all containers. +func TestTracingSidecarMutation(t *testing.T) { + base := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "web"}, + }, + }, + }, + }, + } + + res, err := deployment.NewBuilder(base). + WithMutation(features.TracingSidecarMutation(true)). + Build() + require.NoError(t, err) + + obj, err := res.PreviewObject() + require.NoError(t, err) + + containers := obj.Spec.Template.Spec.Containers + require.Len(t, containers, 2) + + sidecar := containers[1] + assert.Equal(t, "jaeger-agent", sidecar.Name) + assert.Equal(t, "jaegertracing/jaeger-agent:1.28", sidecar.Image) + + for _, c := range containers { + found := false + for _, env := range c.Env { + if env.Name == "JAEGER_AGENT_HOST" { + assert.Equal(t, "localhost", env.Value) + found = true + } + } + assert.True(t, found, "JAEGER_AGENT_HOST not found on container %s", c.Name) + } +} diff --git a/examples/mutations-and-gating/main.go b/examples/mutations-and-gating/main.go new file mode 100644 index 00000000..2e5ece3e --- /dev/null +++ b/examples/mutations-and-gating/main.go @@ -0,0 +1,111 @@ +// Package main demonstrates mutations and resource-level gating. +// +// A single component manages a Deployment and a ConfigMap. The Deployment uses +// version-gated and boolean-gated mutations. The ConfigMap is gated at the +// resource level so it is created only when metrics are enabled. +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/mutations-and-gating/app" + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/record" +) + +func main() { + scheme := runtime.NewScheme() + mustAddToScheme(scheme, app.AddToScheme) + mustAddToScheme(scheme, appsv1.AddToScheme) + mustAddToScheme(scheme, corev1.AddToScheme) + + fakeClient := sharedapp.NewFakeClient(scheme, []sharedapp.RESTMapperEntry{ + {GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, Scope: meta.RESTScopeNamespace}, + {GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, Scope: meta.RESTScopeNamespace}, + {GVK: sharedapp.GroupVersion.WithKind("ExampleApp"), Scope: meta.RESTScopeNamespace}, + }) + + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.9.0", + EnableTracing: true, + EnableMetrics: true, + }, + } + owner.Name = "my-app" + owner.Namespace = "default" + + ctx := context.Background() + if err := fakeClient.Create(ctx, owner); err != nil { + exit("failed to create owner: %v", err) + } + + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.Controller{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example", + OperatorConditionsGauge: gauge, + }, + NewDeploymentResource: resources.NewDeploymentResource, + NewConfigMapResource: resources.NewConfigMapResource, + } + + steps := []app.ExampleAppSpec{ + // 1. v1: legacy container layout ("server", single port), tracing on. + {Version: "1.9.0", EnableTracing: true, EnableMetrics: true}, + // 2. Enable debug logging on v1: LOG_LEVEL=debug targets "app" (baseline + // name) and carries through the legacy rename to "server". + {Version: "1.9.0", EnableTracing: true, EnableMetrics: true, EnableDebugLogging: true}, + // 3. Upgrade to v2: container switches to "app" with health port added. + {Version: "2.0.0", EnableTracing: true, EnableMetrics: true, EnableDebugLogging: true}, + // 4. Disable debug logging and tracing. + {Version: "2.0.0", EnableTracing: false, EnableMetrics: true}, + // 5. Disable metrics: entire ConfigMap deleted via resource gating. + {Version: "2.0.0", EnableTracing: false, EnableMetrics: false}, + // 6. Suspend the application. + {Version: "2.0.0", EnableTracing: false, EnableMetrics: false, Suspended: true}, + } + + for i, spec := range steps { + fmt.Printf("\n--- Step %d: Version=%s DebugLogging=%v Tracing=%v Metrics=%v Suspended=%v ---\n", + i+1, spec.Version, spec.EnableDebugLogging, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) + + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + exit("failed to update owner: %v", err) + } + + if err := controller.Reconcile(ctx, owner); err != nil { + exit("reconciliation failed: %v", err) + } + + for _, c := range owner.Status.Conditions { + fmt.Printf(" Condition: %s Status: %s Reason: %s\n", c.Type, c.Status, c.Reason) + } + } + + fmt.Println("\nDone.") +} + +func mustAddToScheme(scheme *runtime.Scheme, fn func(*runtime.Scheme) error) { + if err := fn(scheme); err != nil { + exit("failed to add to scheme: %v", err) + } +} + +func exit(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/examples/mutations-and-gating/resources/configmap.go b/examples/mutations-and-gating/resources/configmap.go new file mode 100644 index 00000000..270d55e9 --- /dev/null +++ b/examples/mutations-and-gating/resources/configmap.go @@ -0,0 +1,37 @@ +package resources + +import ( + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/app" + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BaseConfigMap returns the desired-state ConfigMap for the given owner. +// The baseline includes the core server configuration in app.yaml. +func BaseConfigMap(owner *app.ExampleApp) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-config", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Data: map[string]string{ + "app.yaml": "server:\n port: 8080\n timeout: 30s\n", + }, + } +} + +// NewConfigMapResource constructs a ConfigMap with server and metrics config. +// The metrics section is boolean-gated at the mutation level, while the entire +// ConfigMap can be gated at the resource level by the controller. +func NewConfigMapResource(owner *app.ExampleApp) (component.Resource, error) { + builder := configmap.NewBuilder(BaseConfigMap(owner)) + builder.WithMutation(features.MetricsConfigMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + return builder.Build() +} diff --git a/examples/mutations-and-gating/resources/configmap_test.go b/examples/mutations-and-gating/resources/configmap_test.go new file mode 100644 index 00000000..d6c175b8 --- /dev/null +++ b/examples/mutations-and-gating/resources/configmap_test.go @@ -0,0 +1,57 @@ +package resources_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// TestConfigMapShape verifies the ConfigMap's rendered YAML against golden +// files for each feature combination. +// +// The baseline ConfigMap carries the core server config in its Data field. +// Boolean-gated mutations (MetricsConfigMutation) layer additional sections +// on top. Golden files pin the output so that changes to the baseline or +// mutation logic surface as test failures. +func TestConfigMapShape(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + tests := []struct { + name string + version string + metrics bool + golden string + }{ + { + name: "baseline", + version: "1.0.0", + golden: "testdata/configmap-baseline.yaml", + }, + { + name: "with metrics", + version: "1.0.0", + metrics: true, + golden: "testdata/configmap-metrics.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := testOwner(tt.version) + + res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)). + WithMutation(features.MetricsConfigMutation(tt.version, tt.metrics)). + Build() + require.NoError(t, err) + + golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update)) + }) + } +} diff --git a/examples/mutations-and-gating/resources/deployment.go b/examples/mutations-and-gating/resources/deployment.go new file mode 100644 index 00000000..106e3145 --- /dev/null +++ b/examples/mutations-and-gating/resources/deployment.go @@ -0,0 +1,68 @@ +// Package resources provides resource factories for the mutations-and-gating example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/app" + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BaseDeployment returns the desired-state Deployment for the given owner. +// +// The baseline represents the latest version (v2+): container named "app" +// with both an HTTP and a health port. Backward compatibility mutations roll +// this back for older versions. +func BaseDeployment(owner *app.ExampleApp) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-app", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": owner.Name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": owner.Name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: fmt.Sprintf("my-app:%s", owner.Spec.Version), + Ports: []corev1.ContainerPort{ + {Name: "http", ContainerPort: 8080}, + {Name: "health", ContainerPort: 8081}, + }, + }, + }, + }, + }, + }, + } +} + +// NewDeploymentResource constructs a Deployment with version-gated and +// boolean-gated mutations. +// +// Registration order matters: DebugLogging targets the baseline container name +// ("app") via ContainerNamed, so it must come before BackwardCompatV1Container +// which renames "app" to "server" for older versions. TracingSidecar uses +// AllContainers and is order-insensitive. +func NewDeploymentResource(owner *app.ExampleApp) (component.Resource, error) { + return deployment.NewBuilder(BaseDeployment(owner)). + WithMutation(features.DebugLoggingMutation(owner.Spec.EnableDebugLogging)). + WithMutation(features.BackwardCompatV1Container(owner.Spec.Version)). + WithMutation(features.TracingSidecarMutation(owner.Spec.EnableTracing)). + Build() +} diff --git a/examples/mutations-and-gating/resources/deployment_test.go b/examples/mutations-and-gating/resources/deployment_test.go new file mode 100644 index 00000000..957eff99 --- /dev/null +++ b/examples/mutations-and-gating/resources/deployment_test.go @@ -0,0 +1,125 @@ +package resources_test + +import ( + "flag" + "testing" + + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features" + "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var update = flag.Bool("update", false, "update golden files") + +func testOwner(version string) *sharedapp.ExampleApp { + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{Version: version}, + } + owner.Name = "my-app" + owner.Namespace = "default" + return owner +} + +// TestDeploymentShape verifies the Deployment's rendered YAML against golden +// files for each supported version and feature combination. +// +// The baseline object (BaseDeployment) always reflects the latest version's +// desired state (v2: container "app", HTTP + health ports). Legacy mutations +// roll it back for older versions (v1: container "server", HTTP port only). +// +// Mutation registration order mirrors NewDeploymentResource: DebugLogging +// targets ContainerNamed("app") and must come before LegacyContainer which +// renames the container. TracingSidecar uses AllContainers and is +// order-insensitive. +// +// These snapshots catch unintended regressions: if someone updates the +// baseline to accommodate a new v3 layout, the v1 and v2 golden files will +// fail unless the corresponding backward compat mutations still produce the +// correct shape. This ensures that changes to the latest version do not silently +// break the resource shape served to older versions. +func TestDeploymentShape(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + + tests := []struct { + name string + version string + debug bool + tracing bool + golden string + }{ + // v1 cases: BackwardCompatV1Container fires and rolls back the v2 + // baseline to the v1 container layout. If the baseline changes, + // these golden files catch any v1 regression. + { + name: "v1 legacy container", + version: "1.9.0", + golden: "testdata/deployment-v1.yaml", + }, + { + name: "v1 with tracing", + version: "1.9.0", + tracing: true, + golden: "testdata/deployment-v1-tracing.yaml", + }, + { + name: "v1 with debug", + version: "1.9.0", + debug: true, + golden: "testdata/deployment-v1-debug.yaml", + }, + { + name: "v1 with tracing and debug", + version: "1.9.0", + debug: true, + tracing: true, + golden: "testdata/deployment-v1-tracing-debug.yaml", + }, + // v2 cases: no legacy mutation fires, so the baseline is rendered + // as-is. These golden files pin the current latest shape. + { + name: "v2 baseline", + version: "2.0.0", + golden: "testdata/deployment-v2.yaml", + }, + { + name: "v2 with tracing", + version: "2.0.0", + tracing: true, + golden: "testdata/deployment-v2-tracing.yaml", + }, + { + name: "v2 with debug", + version: "2.0.0", + debug: true, + golden: "testdata/deployment-v2-debug.yaml", + }, + { + name: "v2 with tracing and debug", + version: "2.0.0", + debug: true, + tracing: true, + golden: "testdata/deployment-v2-tracing-debug.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := testOwner(tt.version) + + res, err := deployment.NewBuilder(resources.BaseDeployment(owner)). + WithMutation(features.DebugLoggingMutation(tt.debug)). + WithMutation(features.BackwardCompatV1Container(tt.version)). + WithMutation(features.TracingSidecarMutation(tt.tracing)). + Build() + require.NoError(t, err) + + golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update)) + }) + } +} diff --git a/examples/mutations-and-gating/resources/testdata/configmap-baseline.yaml b/examples/mutations-and-gating/resources/testdata/configmap-baseline.yaml new file mode 100644 index 00000000..cb512a27 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/configmap-baseline.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +data: + app.yaml: | + server: + port: 8080 + timeout: 30s +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-config + namespace: default diff --git a/examples/mutations-and-gating/resources/testdata/configmap-metrics.yaml b/examples/mutations-and-gating/resources/testdata/configmap-metrics.yaml new file mode 100644 index 00000000..caf17cdc --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/configmap-metrics.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +data: + app.yaml: | + metrics: + enabled: true + path: /metrics + port: 9090 + server: + port: 8080 + timeout: 30s +kind: ConfigMap +metadata: + labels: + app: my-app + name: my-app-config + namespace: default diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v1-debug.yaml new file mode 100644 index 00000000..b4efb40c --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v1-debug.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - env: + - name: LOG_LEVEL + value: debug + image: my-app:1.9.0 + name: server + ports: + - containerPort: 8080 + name: http + resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml new file mode 100644 index 00000000..557acf1d --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - env: + - name: LOG_LEVEL + value: debug + - name: JAEGER_AGENT_HOST + value: localhost + image: my-app:1.9.0 + name: server + ports: + - containerPort: 8080 + name: http + resources: {} + - env: + - name: JAEGER_AGENT_HOST + value: localhost + image: jaegertracing/jaeger-agent:1.28 + name: jaeger-agent + resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing.yaml new file mode 100644 index 00000000..a177c2c3 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - env: + - name: JAEGER_AGENT_HOST + value: localhost + image: my-app:1.9.0 + name: server + ports: + - containerPort: 8080 + name: http + resources: {} + - env: + - name: JAEGER_AGENT_HOST + value: localhost + image: jaegertracing/jaeger-agent:1.28 + name: jaeger-agent + resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v1.yaml new file mode 100644 index 00000000..0223991d --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v1.yaml @@ -0,0 +1,24 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - image: my-app:1.9.0 + name: server + ports: + - containerPort: 8080 + name: http + resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v2-debug.yaml new file mode 100644 index 00000000..0d837fd5 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v2-debug.yaml @@ -0,0 +1,29 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - env: + - name: LOG_LEVEL + value: debug + image: my-app:2.0.0 + name: app + ports: + - containerPort: 8080 + name: http + - containerPort: 8081 + name: health + resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml new file mode 100644 index 00000000..31da05b2 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - env: + - name: LOG_LEVEL + value: debug + - name: JAEGER_AGENT_HOST + value: localhost + image: my-app:2.0.0 + name: app + ports: + - containerPort: 8080 + name: http + - containerPort: 8081 + name: health + resources: {} + - env: + - name: JAEGER_AGENT_HOST + value: localhost + image: jaegertracing/jaeger-agent:1.28 + name: jaeger-agent + resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing.yaml new file mode 100644 index 00000000..b0ea0f88 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - env: + - name: JAEGER_AGENT_HOST + value: localhost + image: my-app:2.0.0 + name: app + ports: + - containerPort: 8080 + name: http + - containerPort: 8081 + name: health + resources: {} + - env: + - name: JAEGER_AGENT_HOST + value: localhost + image: jaegertracing/jaeger-agent:1.28 + name: jaeger-agent + resources: {} diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2.yaml b/examples/mutations-and-gating/resources/testdata/deployment-v2.yaml new file mode 100644 index 00000000..712145c4 --- /dev/null +++ b/examples/mutations-and-gating/resources/testdata/deployment-v2.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: my-app + name: my-app-app + namespace: default +spec: + selector: + matchLabels: + app: my-app + strategy: {} + template: + metadata: + labels: + app: my-app + spec: + containers: + - image: my-app:2.0.0 + name: app + ports: + - containerPort: 8080 + name: http + - containerPort: 8081 + name: health + resources: {} diff --git a/examples/networkpolicy-primitive/README.md b/examples/networkpolicy-primitive/README.md deleted file mode 100644 index 5d1737ee..00000000 --- a/examples/networkpolicy-primitive/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# NetworkPolicy Primitive Example - -This example demonstrates the usage of the `networkpolicy` primitive within the operator component framework. It shows -how to manage a Kubernetes NetworkPolicy as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a NetworkPolicy with pod selector and policy types. -- **Feature Mutations**: Composing ingress and egress rules from independent, feature-gated mutations. -- **Boolean-Gated Rules**: Conditionally adding metrics ingress rules based on a spec flag. -- **Metadata Mutations**: Setting version labels on the NetworkPolicy via metadata editors. -- **Label Coexistence**: Demonstrating how label updates from this component can coexist with labels managed by other - controllers. -- **Data Extraction**: Reading the applied policy configuration 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`: HTTP ingress, boolean-gated metrics ingress, DNS egress, and version labelling. -- `resources/`: Contains the central `NewNetworkPolicyResource` factory that assembles all features using - `networkpolicy.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/networkpolicy-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile through three spec variations, printing the applied policy details after each cycle. -4. Print the resulting status conditions. diff --git a/examples/networkpolicy-primitive/app/controller.go b/examples/networkpolicy-primitive/app/controller.go deleted file mode 100644 index 8bff6323..00000000 --- a/examples/networkpolicy-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the networkpolicy 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 - - // NewNetworkPolicyResource is a factory function to create the networkpolicy resource. - // This allows us to inject the resource construction logic. - NewNetworkPolicyResource 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 networkpolicy resource for this owner. - npResource, err := r.NewNetworkPolicyResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the networkpolicy. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(npResource, 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/networkpolicy-primitive/features/mutations.go b/examples/networkpolicy-primitive/features/mutations.go deleted file mode 100644 index 538fd987..00000000 --- a/examples/networkpolicy-primitive/features/mutations.go +++ /dev/null @@ -1,91 +0,0 @@ -// Package features provides sample mutations for the networkpolicy primitive example. -package features - -import ( - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/util/intstr" - - "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/networkpolicy" -) - -// VersionLabelMutation sets the app.kubernetes.io/version label on the -// NetworkPolicy. It is always enabled (nil Feature gate). -func VersionLabelMutation(version string) networkpolicy.Mutation { - return networkpolicy.Mutation{ - Name: "version-label", - Mutate: func(m *networkpolicy.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// HTTPIngressMutation adds an ingress rule allowing TCP traffic on port 8080. -// It is always enabled (nil Feature gate). -func HTTPIngressMutation() networkpolicy.Mutation { - return networkpolicy.Mutation{ - Name: "http-ingress", - Mutate: func(m *networkpolicy.Mutator) error { - m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { - port := intstr.FromInt32(8080) - tcp := corev1.ProtocolTCP - e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ - Ports: []networkingv1.NetworkPolicyPort{ - {Protocol: &tcp, Port: &port}, - }, - }) - return nil - }) - return nil - }, - } -} - -// MetricsIngressMutation adds an ingress rule allowing TCP traffic on port 9090 -// for Prometheus scraping. It is enabled when enableMetrics is true. -func MetricsIngressMutation(version string, enableMetrics bool) networkpolicy.Mutation { - return networkpolicy.Mutation{ - Name: "metrics-ingress", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), - Mutate: func(m *networkpolicy.Mutator) error { - m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { - port := intstr.FromInt32(9090) - tcp := corev1.ProtocolTCP - e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ - Ports: []networkingv1.NetworkPolicyPort{ - {Protocol: &tcp, Port: &port}, - }, - }) - return nil - }) - return nil - }, - } -} - -// DNSEgressMutation adds an egress rule allowing UDP traffic on port 53 for DNS -// resolution. It is always enabled (nil Feature gate). -func DNSEgressMutation() networkpolicy.Mutation { - return networkpolicy.Mutation{ - Name: "dns-egress", - Mutate: func(m *networkpolicy.Mutator) error { - m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { - port := intstr.FromInt32(53) - udp := corev1.ProtocolUDP - e.AppendEgressRule(networkingv1.NetworkPolicyEgressRule{ - Ports: []networkingv1.NetworkPolicyPort{ - {Protocol: &udp, Port: &port}, - }, - }) - return nil - }) - return nil - }, - } -} diff --git a/examples/networkpolicy-primitive/main.go b/examples/networkpolicy-primitive/main.go deleted file mode 100644 index 09b584bb..00000000 --- a/examples/networkpolicy-primitive/main.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package main is the entry point for the networkpolicy 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/networkpolicy-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/networkpolicy-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - networkingv1 "k8s.io/api/networking/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 := networkingv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add networking/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, - }, - NewNetworkPolicyResource: resources.NewNetworkPolicyResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate how - // feature-gated ingress rules compose from independent mutations. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: true, - EnableMetrics: false, // Disable metrics ingress - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v ---\n", - i+1, spec.Version, 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/networkpolicy-primitive/resources/networkpolicy.go b/examples/networkpolicy-primitive/resources/networkpolicy.go deleted file mode 100644 index 0825847d..00000000 --- a/examples/networkpolicy-primitive/resources/networkpolicy.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package resources provides resource implementations for the networkpolicy primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/networkpolicy-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/networkpolicy" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewNetworkPolicyResource constructs a networkpolicy primitive resource with all the features. -func NewNetworkPolicyResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base NetworkPolicy object. - base := &networkingv1.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-netpol", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: networkingv1.NetworkPolicySpec{ - PodSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": owner.Name, - }, - }, - PolicyTypes: []networkingv1.PolicyType{ - networkingv1.PolicyTypeIngress, - networkingv1.PolicyTypeEgress, - }, - }, - } - - // 2. Initialize the networkpolicy builder. - builder := networkpolicy.NewBuilder(base) - - // 3. Register mutations in dependency order. - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.HTTPIngressMutation()) - builder.WithMutation(features.MetricsIngressMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - builder.WithMutation(features.DNSEgressMutation()) - - // 4. Extract data from the reconciled NetworkPolicy. - builder.WithDataExtractor(func(np networkingv1.NetworkPolicy) error { - fmt.Printf("Reconciled NetworkPolicy: %s\n", np.Name) - fmt.Printf(" PodSelector: %v\n", np.Spec.PodSelector.MatchLabels) - fmt.Printf(" PolicyTypes: %v\n", np.Spec.PolicyTypes) - fmt.Printf(" IngressRules: %d\n", len(np.Spec.Ingress)) - fmt.Printf(" EgressRules: %d\n", len(np.Spec.Egress)) - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/pdb-primitive/README.md b/examples/pdb-primitive/README.md deleted file mode 100644 index bb8b20c5..00000000 --- a/examples/pdb-primitive/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# PodDisruptionBudget Primitive Example - -This example demonstrates the usage of the `pdb` primitive within the operator component framework. It shows how to -manage a Kubernetes PodDisruptionBudget as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a PDB with a percentage-based `MinAvailable` and a label selector. -- **Feature Mutations**: Switching between `MinAvailable` and `MaxUnavailable` based on a feature toggle via `EditSpec`. -- **Metadata Mutations**: Setting version labels on the PDB via `EditObjectMetadata`. -- **Data Extraction**: Inspecting the reconciled PDB's disruption policy 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`: version labelling and feature-gated strict availability. -- `resources/`: Contains the central `NewPDBResource` factory that assembles all features using `pdb.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/pdb-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 PDB disruption policy after each cycle. -4. Print the resulting status conditions. diff --git a/examples/pdb-primitive/app/controller.go b/examples/pdb-primitive/app/controller.go deleted file mode 100644 index a65d68a0..00000000 --- a/examples/pdb-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the PDB 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 - - // NewPDBResource is a factory function to create the PDB resource. - // This allows us to inject the resource construction logic. - NewPDBResource 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 PDB resource for this owner. - pdbResource, err := r.NewPDBResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the PDB. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(pdbResource, 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/pdb-primitive/features/mutations.go b/examples/pdb-primitive/features/mutations.go deleted file mode 100644 index 545a4ed6..00000000 --- a/examples/pdb-primitive/features/mutations.go +++ /dev/null @@ -1,43 +0,0 @@ -// Package features provides sample mutations for the PDB 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/pdb" - "k8s.io/apimachinery/pkg/util/intstr" -) - -// VersionLabelMutation sets the app.kubernetes.io/version label on the PDB. -// It is always enabled. -func VersionLabelMutation(version string) pdb.Mutation { - return pdb.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *pdb.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// StrictAvailabilityMutation switches the PDB from percentage-based MinAvailable -// to an absolute MaxUnavailable of 1 when metrics are enabled, indicating that -// the service requires stricter disruption control. -func StrictAvailabilityMutation(version string, metricsEnabled bool) pdb.Mutation { - return pdb.Mutation{ - Name: "strict-availability", - Feature: feature.NewVersionGate(version, nil).When(metricsEnabled), - Mutate: func(m *pdb.Mutator) error { - m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error { - e.ClearMinAvailable() - e.SetMaxUnavailable(intstr.FromInt32(1)) - return nil - }) - return nil - }, - } -} diff --git a/examples/pdb-primitive/main.go b/examples/pdb-primitive/main.go deleted file mode 100644 index abbc5060..00000000 --- a/examples/pdb-primitive/main.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package main is the entry point for the PDB 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/pdb-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/pdb-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - policyv1 "k8s.io/api/policy/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 := policyv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add policy/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.0.0", - EnableMetrics: false, - }, - } - 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, - }, - NewPDBResource: resources.NewPDBResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate how - // feature-gated mutations modify the PDB disruption policy. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.0.0", - EnableMetrics: false, - }, - { - Version: "1.1.0", // Version upgrade - EnableMetrics: false, - }, - { - Version: "1.1.0", - EnableMetrics: true, // Enable metrics → stricter availability - }, - { - Version: "1.1.0", - EnableMetrics: false, // Disable metrics → back to default - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v ---\n", - i+1, spec.Version, 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/pdb-primitive/resources/pdb.go b/examples/pdb-primitive/resources/pdb.go deleted file mode 100644 index 30f7fb95..00000000 --- a/examples/pdb-primitive/resources/pdb.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package resources provides resource implementations for the PDB primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/pdb-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/pdb" - policyv1 "k8s.io/api/policy/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" -) - -// NewPDBResource constructs a PDB primitive resource with all the features. -func NewPDBResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base PodDisruptionBudget object. - minAvailable := intstr.FromString("50%") - base := &policyv1.PodDisruptionBudget{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-pdb", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: policyv1.PodDisruptionBudgetSpec{ - MinAvailable: &minAvailable, - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": owner.Name, - }, - }, - }, - } - - // 2. Initialize the PDB builder. - builder := pdb.NewBuilder(base) - - // 3. Register mutations in dependency order. - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.StrictAvailabilityMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled PDB. - builder.WithDataExtractor(func(p policyv1.PodDisruptionBudget) error { - fmt.Printf("Reconciled PDB: %s\n", p.Name) - if p.Spec.MinAvailable != nil { - fmt.Printf(" MinAvailable: %s\n", p.Spec.MinAvailable.String()) - } - if p.Spec.MaxUnavailable != nil { - fmt.Printf(" MaxUnavailable: %s\n", p.Spec.MaxUnavailable.String()) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/pod-primitive/README.md b/examples/pod-primitive/README.md deleted file mode 100644 index fee67a4a..00000000 --- a/examples/pod-primitive/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Pod Primitive Example - -This example demonstrates the usage of the `pod` primitive within the operator component framework. It shows how to -manage a Kubernetes Pod as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing a Pod with basic metadata and spec. -- **Feature Mutations**: Applying version-gated or conditional metadata changes (labels) using the `Mutator`. -- **Suspension**: Deleting the pod when the component is suspended (pods cannot be paused). -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: label mutations applied to the Pod using the `Mutator`. -- `resources/`: Contains the central `NewPodResource` factory that assembles all features using the `pod.Builder`. -- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/pod-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components through multiple spec changes. -4. Print the resulting status conditions. diff --git a/examples/pod-primitive/app/controller.go b/examples/pod-primitive/app/controller.go deleted file mode 100644 index 3a69a2f5..00000000 --- a/examples/pod-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the pod primitive. -package app - -import ( - "context" - - "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 - - // NewPodResource is a factory function to create the pod resource. - // This allows us to inject the resource construction logic. - NewPodResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the pod resource for this owner. - podResource, err := r.NewPodResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the pod. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(podResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/pod-primitive/app/owner.go b/examples/pod-primitive/app/owner.go deleted file mode 100644 index 6b611a02..00000000 --- a/examples/pod-primitive/app/owner.go +++ /dev/null @@ -1,20 +0,0 @@ -package app - -import ( - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" -) - -// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. -type ExampleApp = sharedapp.ExampleApp - -// ExampleAppSpec re-exports the shared spec type. -type ExampleAppSpec = sharedapp.ExampleAppSpec - -// ExampleAppStatus re-exports the shared status type. -type ExampleAppStatus = sharedapp.ExampleAppStatus - -// ExampleAppList re-exports the shared list type. -type ExampleAppList = sharedapp.ExampleAppList - -// AddToScheme registers the ExampleApp types with the given scheme. -var AddToScheme = sharedapp.AddToScheme diff --git a/examples/pod-primitive/features/mutations.go b/examples/pod-primitive/features/mutations.go deleted file mode 100644 index 140c86ad..00000000 --- a/examples/pod-primitive/features/mutations.go +++ /dev/null @@ -1,47 +0,0 @@ -// Package features provides sample mutations for the pod primitive example. -// -// These examples only mutate object metadata because most Pod spec fields -// are immutable after creation. Kubernetes does allow a small set of in-place -// updates (notably container images), but other fields such as env, args, and -// resources require the Pod to be deleted and recreated. -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/pod" -) - -// TracingFeature marks the pod as tracing-enabled via metadata. -// Controllers or sidecar injectors can watch the label and handle -// any required Pod replacement or injection. -func TracingFeature(enabled bool) pod.Mutation { - return pod.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *pod.Mutator) error { - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("sidecar.jaegertracing.io/inject", "true") - return nil - }) - return nil - }, - } -} - -// VersionFeature records the desired version on the pod as a label. -// It avoids mutating container images directly; while Kubernetes allows -// in-place image updates, this example keeps mutations in metadata only. -func VersionFeature(version string) pod.Mutation { - return pod.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *pod.Mutator) error { - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} diff --git a/examples/pod-primitive/main.go b/examples/pod-primitive/main.go deleted file mode 100644 index 350124a7..00000000 --- a/examples/pod-primitive/main.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package main is the entry point for the pod 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/pod-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/resources" - 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 for the example. - scheme := runtime.NewScheme() - if err := app.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(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: false, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the pod resource factory. - NewPodResource: resources.NewPodResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/pod-primitive/resources/pod.go b/examples/pod-primitive/resources/pod.go deleted file mode 100644 index 45d1cb6d..00000000 --- a/examples/pod-primitive/resources/pod.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package resources provides resource implementations for the pod primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -// NewPodResource constructs a pod primitive resource with all the features. -func NewPodResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base pod object. - base := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-pod", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "my-app:latest", // Base image for the app container - }, - }, - }, - } - - // 2. Initialize the pod builder. - builder := pod.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionFeature(owner.Spec.Version)) - builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - - // 4. Data extraction (optional). - builder.WithDataExtractor(func(p corev1.Pod) error { - fmt.Printf("Reconciling pod: %s, phase: %s\n", p.Name, p.Status.Phase) - - // Print the complete pod resource object as yaml - y, err := yaml.Marshal(p) - if err != nil { - return fmt.Errorf("failed to marshal pod to yaml: %w", err) - } - fmt.Printf("Complete Pod Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/pv-primitive/README.md b/examples/pv-primitive/README.md deleted file mode 100644 index 7e0d589e..00000000 --- a/examples/pv-primitive/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# PersistentVolume Primitive Example - -This example demonstrates the usage of the `pv` primitive within the operator component framework. It shows how to -manage a Kubernetes PersistentVolume as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a cluster-scoped PersistentVolume with storage configuration. -- **Feature Mutations**: Applying feature-gated changes to reclaim policy, mount options, and metadata. -- **Field Flavors**: Preserving annotations managed by external controllers using `PreserveCurrentAnnotations`. -- **Result Inspection**: Printing PV configuration 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, boolean-gated retain policy, and mount options mutations. -- `resources/`: Contains the central `NewPVResource` factory that assembles all features using `pv.Builder`. -- `main.go`: A standalone entry point that demonstrates in-memory mutation across multiple spec variations. - -## Running the Example - -```bash -go run examples/pv-primitive/main.go -``` - -This will: - -1. Create an `ExampleApp` owner object. -2. Apply mutations across four spec variations, printing the resulting PV YAML after each cycle. -3. Print operational status examples for each PV phase. diff --git a/examples/pv-primitive/app/controller.go b/examples/pv-primitive/app/controller.go deleted file mode 100644 index e24403c9..00000000 --- a/examples/pv-primitive/app/controller.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package app provides a sample controller using the pv primitive. -// -// PersistentVolumes are cluster-scoped resources. In a real operator, the owner -// would typically be a cluster-scoped CRD (e.g. a ClusterStorageConfig). When a -// component create pipeline is used, it will only set controller references where -// the owner/owned scopes are compatible, and will skip ownerReferences for -// cluster-scoped resources that would otherwise have namespace-scoped owners. In -// those skipped cases, the cluster-scoped resource will not be garbage-collected -// with the namespaced owner. -// -// This example demonstrates the PV primitive's builder, mutation, and status APIs -// without full component reconciliation to keep the focus on the primitive itself. -package app diff --git a/examples/pv-primitive/features/mutations.go b/examples/pv-primitive/features/mutations.go deleted file mode 100644 index e6250832..00000000 --- a/examples/pv-primitive/features/mutations.go +++ /dev/null @@ -1,61 +0,0 @@ -// Package features provides sample mutations for the pv primitive example. -package features - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" - "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/pv" - corev1 "k8s.io/api/core/v1" -) - -// VersionLabelMutation sets the app.kubernetes.io/version label on the PersistentVolume. -// It is always enabled. -func VersionLabelMutation(version string) pv.Mutation { - return pv.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *pv.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// RetainPolicyMutation sets the reclaim policy to Retain when enabled. -// This is gated by a boolean condition. -func RetainPolicyMutation(version string, enabled bool) pv.Mutation { - return pv.Mutation{ - Name: "retain-policy", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *pv.Mutator) error { - m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain) - return nil - }, - } -} - -// MountOptionsMutation adds NFS mount options when enabled. -// This is gated by a boolean condition. -func MountOptionsMutation(version string, enabled bool) pv.Mutation { - return pv.Mutation{ - Name: "mount-options", - Feature: feature.NewVersionGate(version, nil).When(enabled), - Mutate: func(m *pv.Mutator) error { - m.SetMountOptions([]string{"hard", "nfsvers=4.1"}) - return nil - }, - } -} - -// ExampleOperationalStatus demonstrates the default operational status handler -// by returning the status for a given PV phase. -func ExampleOperationalStatus(phase corev1.PersistentVolumePhase) (concepts.OperationalStatusWithReason, error) { - p := &corev1.PersistentVolume{ - Status: corev1.PersistentVolumeStatus{Phase: phase}, - } - return pv.DefaultOperationalStatusHandler(concepts.ConvergingOperationNone, p) -} diff --git a/examples/pv-primitive/main.go b/examples/pv-primitive/main.go deleted file mode 100644 index f1099971..00000000 --- a/examples/pv-primitive/main.go +++ /dev/null @@ -1,121 +0,0 @@ -// Package main is the entry point for the pv primitive example. -package main - -import ( - "fmt" - "os" - - "github.com/sourcehawk/operator-component-framework/examples/pv-primitive/features" - "github.com/sourcehawk/operator-component-framework/examples/pv-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -func main() { - // 1. Create an example Owner object. - owner := &sharedapp.ExampleApp{ - Spec: sharedapp.ExampleAppSpec{ - Version: "1.0.0", - EnableTracing: false, - EnableMetrics: false, - }, - } - owner.Name = "my-storage-app" - owner.Namespace = "default" - - // 2. Run through multiple spec variations to demonstrate how mutations compose. - // EnableTracing gates the retain-policy mutation; EnableMetrics gates mount options. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.0.0", - EnableTracing: false, - EnableMetrics: false, - }, - { - Version: "2.0.0", // Version upgrade - EnableTracing: false, - EnableMetrics: false, - }, - { - Version: "2.0.0", - EnableTracing: true, // Enable retain policy - EnableMetrics: false, - }, - { - Version: "2.0.0", - EnableTracing: true, - EnableMetrics: true, // Enable mount options - }, - } - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, RetainPolicy=%v, MountOpts=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics) - - owner.Spec = spec - - // Build the PV resource for this spec. - pvResource, err := resources.NewPVResource(owner) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to build PV resource: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Identity: %s\n", pvResource.Identity()) - - // Simulate a current PV from the cluster (what CreateOrUpdate would provide). - current := &corev1.PersistentVolume{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-data", - ResourceVersion: "12345", // non-empty to simulate existing object - Annotations: map[string]string{ - "external-controller/managed": "true", // preserved: mutations only touch fields they explicitly target - }, - }, - Spec: corev1.PersistentVolumeSpec{ - Capacity: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("50Gi"), - }, - PersistentVolumeSource: corev1.PersistentVolumeSource{ - HostPath: &corev1.HostPathVolumeSource{Path: "/mnt/old-data"}, - }, - }, - } - - // Apply mutations to the current object. - if err := pvResource.Mutate(current); err != nil { - fmt.Fprintf(os.Stderr, "mutation failed: %v\n", err) - os.Exit(1) - } - - // Print the result. - y, err := yaml.Marshal(current) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to marshal PV: %v\n", err) - os.Exit(1) - } - fmt.Printf("Resulting PV:\n%s", string(y)) - - // Show that immutable fields were preserved from current. - var preservedPath string - if hp := current.Spec.HostPath; hp != nil { - preservedPath = hp.Path - } - fmt.Printf("Preserved volume source path: %s (original: /mnt/old-data)\n", preservedPath) - } - - // 3. Demonstrate the operational status handler. - fmt.Println("\n--- Operational Status Examples ---") - for _, phase := range []corev1.PersistentVolumePhase{ - corev1.VolumeAvailable, corev1.VolumeBound, corev1.VolumePending, - corev1.VolumeReleased, corev1.VolumeFailed, - } { - status, _ := features.ExampleOperationalStatus(phase) - fmt.Printf("Phase %-10s → Status: %s, Reason: %s\n", phase, status.Status, status.Reason) - } - - fmt.Println("\nExample completed successfully!") -} diff --git a/examples/pv-primitive/resources/pv.go b/examples/pv-primitive/resources/pv.go deleted file mode 100644 index e30bb41d..00000000 --- a/examples/pv-primitive/resources/pv.go +++ /dev/null @@ -1,50 +0,0 @@ -// Package resources provides resource implementations for the pv primitive example. -package resources - -import ( - "github.com/sourcehawk/operator-component-framework/examples/pv-primitive/features" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/pv" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewPVResource constructs a PersistentVolume primitive resource with all the features. -func NewPVResource(owner *sharedapp.ExampleApp) (*pv.Resource, error) { - // 1. Create the base PersistentVolume object. - // PVs are cluster-scoped — no namespace is set. - base := &corev1.PersistentVolume{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-data", - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PersistentVolumeSpec{ - Capacity: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("100Gi"), - }, - AccessModes: []corev1.PersistentVolumeAccessMode{ - corev1.ReadWriteOnce, - }, - PersistentVolumeSource: corev1.PersistentVolumeSource{ - HostPath: &corev1.HostPathVolumeSource{ - Path: "/mnt/data", - }, - }, - StorageClassName: "standard", - }, - } - - // 2. Initialize the PV builder. - builder := pv.NewBuilder(base) - - // 3. Register mutations in dependency order. - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.RetainPolicyMutation(owner.Spec.Version, owner.Spec.EnableTracing)) - builder.WithMutation(features.MountOptionsMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Build the final resource. - return builder.Build() -} diff --git a/examples/pvc-primitive/README.md b/examples/pvc-primitive/README.md deleted file mode 100644 index 9f7e123b..00000000 --- a/examples/pvc-primitive/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# PVC Primitive Example - -This example demonstrates the usage of the `pvc` primitive within the operator component framework. It shows how to -manage a Kubernetes PersistentVolumeClaim as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a PVC with access modes, storage request, and metadata. -- **Feature Mutations**: Applying conditional storage expansion and metadata updates using the `Mutator`. -- **Suspension**: PVCs are immediately suspended with data preserved by default. -- **Data Extraction**: Harvesting PVC status 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, storage annotation, and conditional large-storage expansion. -- `resources/`: Contains the central `NewPVCResource` factory that assembles all features using `pvc.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/pvc-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile through four spec variations, demonstrating version upgrades, storage expansion, and suspension. -4. Print the resulting status conditions. diff --git a/examples/pvc-primitive/app/controller.go b/examples/pvc-primitive/app/controller.go deleted file mode 100644 index 9d055e2a..00000000 --- a/examples/pvc-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the PVC primitive. -package app - -import ( - "context" - - "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 - - // NewPVCResource is a factory function to create the PVC resource. - // This allows us to inject the resource construction logic. - NewPVCResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the PVC resource for this owner. - pvcResource, err := r.NewPVCResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the PVC. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(pvcResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/pvc-primitive/app/owner.go b/examples/pvc-primitive/app/owner.go deleted file mode 100644 index 6b611a02..00000000 --- a/examples/pvc-primitive/app/owner.go +++ /dev/null @@ -1,20 +0,0 @@ -package app - -import ( - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" -) - -// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. -type ExampleApp = sharedapp.ExampleApp - -// ExampleAppSpec re-exports the shared spec type. -type ExampleAppSpec = sharedapp.ExampleAppSpec - -// ExampleAppStatus re-exports the shared status type. -type ExampleAppStatus = sharedapp.ExampleAppStatus - -// ExampleAppList re-exports the shared list type. -type ExampleAppList = sharedapp.ExampleAppList - -// AddToScheme registers the ExampleApp types with the given scheme. -var AddToScheme = sharedapp.AddToScheme diff --git a/examples/pvc-primitive/features/mutations.go b/examples/pvc-primitive/features/mutations.go deleted file mode 100644 index 0e1fc83e..00000000 --- a/examples/pvc-primitive/features/mutations.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package features provides sample mutations for the PVC 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/pvc" - "k8s.io/apimachinery/pkg/api/resource" -) - -// VersionLabelMutation sets the app.kubernetes.io/version label on the PVC. -// It is always enabled. -func VersionLabelMutation(version string) pvc.Mutation { - return pvc.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *pvc.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// LargeStorageMutation expands the PVC storage request to 50Gi. -// It is enabled when needsLargeStorage is true. -func LargeStorageMutation(version string, needsLargeStorage bool) pvc.Mutation { - return pvc.Mutation{ - Name: "large-storage", - Feature: feature.NewVersionGate(version, nil).When(needsLargeStorage), - Mutate: func(m *pvc.Mutator) error { - m.SetStorageRequest(resource.MustParse("50Gi")) - return nil - }, - } -} - -// StorageAnnotationMutation adds a storage-class hint annotation to the PVC. -// It is always enabled. -func StorageAnnotationMutation(version string, storageClass string) pvc.Mutation { - return pvc.Mutation{ - Name: "storage-annotation", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *pvc.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureAnnotation("storage/class-hint", storageClass) - return nil - }) - return nil - }, - } -} diff --git a/examples/pvc-primitive/main.go b/examples/pvc-primitive/main.go deleted file mode 100644 index ec5ff891..00000000 --- a/examples/pvc-primitive/main.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package main is the entry point for the PVC 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/pvc-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/pvc-primitive/resources" - 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 := app.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(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: false, - EnableMetrics: false, - Suspended: false, - }, - } - 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, - }, - NewPVCResource: resources.NewPVCResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate - // how PVC mutations compose across features. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: false, - EnableMetrics: false, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: false, - EnableMetrics: false, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: true, // Triggers large storage mutation - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: true, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableMetrics, spec.Suspended) - - 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/pvc-primitive/resources/pvc.go b/examples/pvc-primitive/resources/pvc.go deleted file mode 100644 index 3689f3d0..00000000 --- a/examples/pvc-primitive/resources/pvc.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package resources provides resource implementations for the PVC primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/pvc-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/pvc-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/pvc" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewPVCResource constructs a PVC primitive resource with all the features. -func NewPVCResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base PVC object. - base := &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-data", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("10Gi"), - }, - }, - }, - } - - // 2. Initialize the PVC builder. - builder := pvc.NewBuilder(base) - - // 3. Register mutations in dependency order. - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.StorageAnnotationMutation(owner.Spec.Version, "standard")) - builder.WithMutation(features.LargeStorageMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled PVC. - builder.WithDataExtractor(func(p corev1.PersistentVolumeClaim) error { - fmt.Printf("Reconciled PVC: %s\n", p.Name) - fmt.Printf(" Phase: %s\n", p.Status.Phase) - if storage, ok := p.Spec.Resources.Requests[corev1.ResourceStorage]; ok { - fmt.Printf(" Storage Request: %s\n", storage.String()) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/replicaset-primitive/README.md b/examples/replicaset-primitive/README.md deleted file mode 100644 index 3ccbbce1..00000000 --- a/examples/replicaset-primitive/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# ReplicaSet Primitive Example - -This example demonstrates the usage of the `replicaset` primitive within the operator component framework. It shows how -to manage a Kubernetes ReplicaSet as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing a ReplicaSet with basic metadata and spec. -- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the - `Mutator`. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: sidecar injection, env vars, and version-based image updates. -- `resources/`: Contains the central `NewReplicaSetResource` factory that assembles all features using the - `replicaset.Builder`. -- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/replicaset-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components. -4. Print the resulting status conditions. diff --git a/examples/replicaset-primitive/app/controller.go b/examples/replicaset-primitive/app/controller.go deleted file mode 100644 index 1b2e569a..00000000 --- a/examples/replicaset-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the replicaset primitive. -package app - -import ( - "context" - - "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 - - // NewReplicaSetResource is a factory function to create the replicaset resource. - // This allows us to inject the resource construction logic. - NewReplicaSetResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - // 1. Build the replicaset resource for this owner. - rsResource, err := r.NewReplicaSetResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the replicaset. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(rsResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/replicaset-primitive/app/owner.go b/examples/replicaset-primitive/app/owner.go deleted file mode 100644 index 6b611a02..00000000 --- a/examples/replicaset-primitive/app/owner.go +++ /dev/null @@ -1,20 +0,0 @@ -package app - -import ( - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" -) - -// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. -type ExampleApp = sharedapp.ExampleApp - -// ExampleAppSpec re-exports the shared spec type. -type ExampleAppSpec = sharedapp.ExampleAppSpec - -// ExampleAppStatus re-exports the shared status type. -type ExampleAppStatus = sharedapp.ExampleAppStatus - -// ExampleAppList re-exports the shared list type. -type ExampleAppList = sharedapp.ExampleAppList - -// AddToScheme registers the ExampleApp types with the given scheme. -var AddToScheme = sharedapp.AddToScheme diff --git a/examples/replicaset-primitive/features/mutations.go b/examples/replicaset-primitive/features/mutations.go deleted file mode 100644 index 57d3fb59..00000000 --- a/examples/replicaset-primitive/features/mutations.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package features provides feature plan mutations for the replicaset-primitive example. -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" - corev1 "k8s.io/api/core/v1" -) - -// TracingFeature adds a Jaeger sidecar to the replicaset. -func TracingFeature(enabled bool) replicaset.Mutation { - return replicaset.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *replicaset.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "JAEGER_AGENT_HOST", - Value: "localhost", - }) - - return nil - }, - } -} - -// MetricsFeature adds an exporter sidecar and some annotations. -func MetricsFeature(enabled bool, port int) replicaset.Mutation { - return replicaset.Mutation{ - Name: "Metrics", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *replicaset.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "prometheus-exporter", - Image: "prom/node-exporter:v1.3.1", - }) - - m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("prometheus.io/scrape", "true") - meta.EnsureAnnotation("prometheus.io/port", fmt.Sprintf("%d", port)) - return nil - }) - - return nil - }, - } -} - -// VersionFeature sets the image version and a label. -func VersionFeature(version string) replicaset.Mutation { - return replicaset.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *replicaset.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-app:%s", version) - return nil - }) - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - - return nil - }, - } -} diff --git a/examples/replicaset-primitive/main.go b/examples/replicaset-primitive/main.go deleted file mode 100644 index 24efa3c1..00000000 --- a/examples/replicaset-primitive/main.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package main is the entry point for the replicaset 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/replicaset-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/resources" - appsv1 "k8s.io/api/apps/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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := appsv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add apps/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - // Pass the replicaset resource factory. - NewReplicaSetResource: resources.NewReplicaSetResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: true, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/replicaset-primitive/resources/replicaset.go b/examples/replicaset-primitive/resources/replicaset.go deleted file mode 100644 index 29b9febe..00000000 --- a/examples/replicaset-primitive/resources/replicaset.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package resources provides resource implementations for the replicaset primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -// NewReplicaSetResource constructs a replicaset primitive resource with all the features. -func NewReplicaSetResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base replicaset object. - base := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-replicaset", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: appsv1.ReplicaSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": owner.Name, - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "my-app:latest", // Will be overwritten by VersionFeature - }, - }, - }, - }, - }, - } - - // 2. Initialize the replicaset builder. - builder := replicaset.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionFeature(owner.Spec.Version)) - builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics, 9090)) - - // 4. Data extraction (optional). - builder.WithDataExtractor(func(rs appsv1.ReplicaSet) error { - fmt.Printf("Reconciling replicaset: %s, ready replicas: %d\n", rs.Name, rs.Status.ReadyReplicas) - - // Print the complete replicaset resource object as yaml - y, err := yaml.Marshal(rs) - if err != nil { - return fmt.Errorf("failed to marshal replicaset to yaml: %w", err) - } - fmt.Printf("Complete ReplicaSet Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/role-primitive/README.md b/examples/role-primitive/README.md deleted file mode 100644 index 9b6f5473..00000000 --- a/examples/role-primitive/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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 -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile through four spec variations, printing the composed RBAC rules after each cycle. -4. Print the resulting status conditions. diff --git a/examples/role-primitive/app/controller.go b/examples/role-primitive/app/controller.go deleted file mode 100644 index 64562a31..00000000 --- a/examples/role-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// 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) -} diff --git a/examples/role-primitive/features/mutations.go b/examples/role-primitive/features/mutations.go deleted file mode 100644 index 695c81a7..00000000 --- a/examples/role-primitive/features/mutations.go +++ /dev/null @@ -1,92 +0,0 @@ -// Package features provides sample mutations for the role 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/role" - rbacv1 "k8s.io/api/rbac/v1" -) - -// BaseRuleMutation sets the foundational RBAC rules for the application. -// It is always enabled. -func BaseRuleMutation(version string) role.Mutation { - return role.Mutation{ - Name: "base-rules", - Feature: feature.NewVersionGate(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"}, - }, - { - APIGroups: []string{""}, - Resources: []string{"configmaps"}, - Verbs: []string{"get", "list"}, - }, - }) - return nil - }) - return nil - }, - } -} - -// VersionLabelMutation sets the app.kubernetes.io/version label on the Role. -// It is always enabled. -func VersionLabelMutation(version string) role.Mutation { - return role.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *role.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// SecretAccessMutation adds permission to read secrets. -// It is enabled when enableTracing is true (tracing requires reading TLS secrets). -func SecretAccessMutation(version string, enableTracing bool) role.Mutation { - return role.Mutation{ - Name: "secret-access", - Feature: feature.NewVersionGate(version, nil).When(enableTracing), - 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 - }, - } -} - -// MetricsAccessMutation adds permission to read services for metrics scraping. -// It is enabled when enableMetrics is true. -func MetricsAccessMutation(version string, enableMetrics bool) role.Mutation { - return role.Mutation{ - Name: "metrics-access", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), - Mutate: func(m *role.Mutator) error { - m.EditRules(func(e *editors.PolicyRulesEditor) error { - e.AddRule(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"services", "endpoints"}, - Verbs: []string{"get", "list", "watch"}, - }) - return nil - }) - return nil - }, - } -} diff --git a/examples/role-primitive/main.go b/examples/role-primitive/main.go deleted file mode 100644 index 19f55ffb..00000000 --- a/examples/role-primitive/main.go +++ /dev/null @@ -1,115 +0,0 @@ -// Package main is the entry point for the role 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/role-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/role-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - rbacv1 "k8s.io/api/rbac/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 := rbacv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add rbac/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, - }, - NewRoleResource: resources.NewRoleResource, - } - - // 4. Run reconciliation with multiple spec variations to demonstrate how - // feature-gated mutations compose RBAC rules. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing (removes secret access) - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: false, // Disable metrics too - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Tracing=%v, Metrics=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics) - - owner.Spec = spec - if err := fakeClient.Update(ctx, owner); err != nil { - fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) - os.Exit(1) - } - - fmt.Println("Running reconciliation...") - if err := controller.Reconcile(ctx, owner); err != nil { - fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) - os.Exit(1) - } - - for _, cond := range owner.Status.Conditions { - fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", - cond.Type, cond.Status, cond.Reason) - } - } - - fmt.Println("\nReconciliation sequence completed successfully!") -} diff --git a/examples/role-primitive/resources/role.go b/examples/role-primitive/resources/role.go deleted file mode 100644 index 90b7f585..00000000 --- a/examples/role-primitive/resources/role.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package resources provides resource implementations for the role primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/role-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/role" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewRoleResource constructs a role primitive resource with all the features. -func NewRoleResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base Role object with core permissions. - base := &rbacv1.Role{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-role", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - } - - // 2. Initialize the role builder. - builder := role.NewBuilder(base) - - // 3. Register mutations in dependency order. - // - // BaseRuleMutation sets the foundational permissions. The version label - // mutation always runs to track the app version. Secret access is - // conditionally added based on the tracing flag (as a stand-in for a - // feature that requires reading secrets). - builder.WithMutation(features.BaseRuleMutation(owner.Spec.Version)) - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsAccessMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled Role. - builder.WithDataExtractor(func(r rbacv1.Role) error { - fmt.Printf("Reconciled Role: %s\n", r.Name) - for i, rule := range r.Rules { - fmt.Printf(" Rule %d: apiGroups=%v resources=%v verbs=%v\n", - i, rule.APIGroups, rule.Resources, rule.Verbs) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/rolebinding-primitive/README.md b/examples/rolebinding-primitive/README.md deleted file mode 100644 index 8459c5b5..00000000 --- a/examples/rolebinding-primitive/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# RoleBinding Primitive Example - -This example demonstrates the usage of the `rolebinding` primitive within the operator component framework. It shows how -to manage a Kubernetes RoleBinding as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a RoleBinding with an immutable `roleRef` and basic metadata. -- **Feature Mutations**: Composing subjects from independent, feature-gated mutations using `EditSubjects`. -- **Metadata Mutations**: Setting version labels on the RoleBinding via `EditObjectMetadata`. -- **Data Extraction**: Inspecting subjects and roleRef after each reconcile cycle. - -## Directory Structure - -- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from - `examples/shared/app`. -- `features/`: Contains modular feature definitions: - - `mutations.go`: base subject binding, version labelling, and feature-gated monitoring subject. -- `resources/`: Contains the central `NewRoleBindingResource` factory that assembles all features using - `rolebinding.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/rolebinding-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile through three spec variations, printing the subjects after each cycle. -4. Print the resulting status conditions. diff --git a/examples/rolebinding-primitive/app/controller.go b/examples/rolebinding-primitive/app/controller.go deleted file mode 100644 index 48c86317..00000000 --- a/examples/rolebinding-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the rolebinding 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 - - // NewRoleBindingResource is a factory function to create the rolebinding resource. - // This allows us to inject the resource construction logic. - NewRoleBindingResource 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 rolebinding resource for this owner. - rbResource, err := r.NewRoleBindingResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the rolebinding. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(rbResource, 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/rolebinding-primitive/features/mutations.go b/examples/rolebinding-primitive/features/mutations.go deleted file mode 100644 index 2dd5b7a2..00000000 --- a/examples/rolebinding-primitive/features/mutations.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package features provides sample mutations for the rolebinding 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/rolebinding" - rbacv1 "k8s.io/api/rbac/v1" -) - -// BaseSubjectsMutation adds the application's primary service account as a -// subject. It is always enabled. -func BaseSubjectsMutation(version, saName, saNamespace string) rolebinding.Mutation { - return rolebinding.Mutation{ - Name: "base-subjects", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *rolebinding.Mutator) error { - m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureSubject(rbacv1.Subject{ - Kind: "ServiceAccount", - Name: saName, - Namespace: saNamespace, - }) - return nil - }) - return nil - }, - } -} - -// VersionLabelMutation sets the app.kubernetes.io/version label on the -// RoleBinding. It is always enabled. -func VersionLabelMutation(version string) rolebinding.Mutation { - return rolebinding.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *rolebinding.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// MonitoringSubjectMutation adds a monitoring service account as a subject. -// It is enabled when enableMonitoring is true. -func MonitoringSubjectMutation(version string, enableMonitoring bool) rolebinding.Mutation { - return rolebinding.Mutation{ - Name: "monitoring-subject", - Feature: feature.NewVersionGate(version, nil).When(enableMonitoring), - Mutate: func(m *rolebinding.Mutator) error { - m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.EnsureSubject(rbacv1.Subject{ - Kind: "ServiceAccount", - Name: "monitoring-agent", - Namespace: "monitoring", - }) - return nil - }) - return nil - }, - } -} diff --git a/examples/rolebinding-primitive/main.go b/examples/rolebinding-primitive/main.go deleted file mode 100644 index ba1aba33..00000000 --- a/examples/rolebinding-primitive/main.go +++ /dev/null @@ -1,106 +0,0 @@ -// Package main is the entry point for the rolebinding 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/rolebinding-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/rolebinding-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - rbacv1 "k8s.io/api/rbac/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 := rbacv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add rbac/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", - 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, - }, - NewRoleBindingResource: resources.NewRoleBindingResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate how - // subject mutations compose from independent feature mutations. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableMetrics: true, // monitoring subject enabled - }, - { - Version: "1.2.4", // Version upgrade - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableMetrics: false, // Disable monitoring subject - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Monitoring=%v ---\n", - i+1, spec.Version, 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/rolebinding-primitive/resources/rolebinding.go b/examples/rolebinding-primitive/resources/rolebinding.go deleted file mode 100644 index 67ac4ce1..00000000 --- a/examples/rolebinding-primitive/resources/rolebinding.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package resources provides resource implementations for the rolebinding primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/rolebinding-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/rolebinding" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewRoleBindingResource constructs a rolebinding primitive resource with all the features. -func NewRoleBindingResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base RoleBinding object. - // - // roleRef is set here because it is immutable after creation. - // Subjects are managed via mutations. - base := &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-binding", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "Role", - Name: owner.Name + "-role", - }, - } - - // 2. Initialize the rolebinding builder. - builder := rolebinding.NewBuilder(base) - - // 3. Register mutations in dependency order. - builder.WithMutation(features.BaseSubjectsMutation( - owner.Spec.Version, owner.Name+"-sa", owner.Namespace, - )) - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.MonitoringSubjectMutation( - owner.Spec.Version, owner.Spec.EnableMetrics, - )) - - // 4. Extract data from the reconciled RoleBinding. - builder.WithDataExtractor(func(rb rbacv1.RoleBinding) error { - fmt.Printf("Reconciled RoleBinding: %s\n", rb.Name) - fmt.Printf(" RoleRef: %s/%s\n", rb.RoleRef.Kind, rb.RoleRef.Name) - for _, s := range rb.Subjects { - fmt.Printf(" Subject: %s/%s (ns: %s)\n", s.Kind, s.Name, s.Namespace) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/secret-primitive/README.md b/examples/secret-primitive/README.md deleted file mode 100644 index c87f2144..00000000 --- a/examples/secret-primitive/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Secret Primitive Example - -This example demonstrates the usage of the `secret` primitive within the operator component framework. It shows how to -manage a Kubernetes Secret as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a Secret with basic metadata and type. -- **Feature Mutations**: Composing secret entries from independent, feature-gated mutations using `SetStringData`. -- **Metadata Mutations**: Setting version labels on the Secret via `EditObjectMetadata`. -- **Data Extraction**: Harvesting Secret entries after each reconcile cycle. - -## Directory Structure - -- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from - `examples/shared/app`. -- `features/`: Contains modular feature definitions: - - `mutations.go`: base credentials, version labelling, and feature-gated tracing and metrics tokens. -- `resources/`: Contains the central `NewSecretResource` factory that assembles all features using `secret.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/secret-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile through four spec variations, printing the secret entries after each cycle. -4. Print the resulting status conditions. diff --git a/examples/secret-primitive/app/controller.go b/examples/secret-primitive/app/controller.go deleted file mode 100644 index 339befb3..00000000 --- a/examples/secret-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package app provides a sample controller using the secret primitive. -package app - -import ( - "context" - - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// ExampleController reconciles an ExampleApp object using the component framework. -type ExampleController struct { - client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - Metrics component.Recorder - - // NewSecretResource is a factory function to create the secret resource. - // This allows us to inject the resource construction logic. - NewSecretResource func(*sharedapp.ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *sharedapp.ExampleApp) error { - // 1. Build the secret resource for this owner. - secretResource, err := r.NewSecretResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the secret. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(secretResource, component.ResourceOptions{}). - Build() - if err != nil { - return err - } - - // 3. Execute the component reconciliation. - resCtx := component.ReconcileContext{ - Client: r.Client, - Scheme: r.Scheme, - Recorder: r.Recorder, - Metrics: r.Metrics, - Owner: owner, - } - - return comp.Reconcile(ctx, resCtx) -} diff --git a/examples/secret-primitive/features/mutations.go b/examples/secret-primitive/features/mutations.go deleted file mode 100644 index 2e89987a..00000000 --- a/examples/secret-primitive/features/mutations.go +++ /dev/null @@ -1,67 +0,0 @@ -// Package features provides sample mutations for the secret primitive example. -package features - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" -) - -// BaseCredentialsMutation writes the application's core credentials into the Secret. -// It is always enabled. -// -// NOTE: Real controllers must never hard-code credentials. Source them from -// external secret stores, environment variables, or operator CR fields. -func BaseCredentialsMutation(version string) secret.Mutation { - return secret.Mutation{ - Name: "base-credentials", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *secret.Mutator) error { - m.SetStringData("username", "REPLACE_ME") - m.SetStringData("password", "REPLACE_ME") - return nil - }, - } -} - -// VersionLabelMutation sets the app.kubernetes.io/version label on the Secret. -// It is always enabled. -func VersionLabelMutation(version string) secret.Mutation { - return secret.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *secret.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// TracingTokenMutation adds an OpenTelemetry tracing auth token to the Secret. -// It is enabled when enableTracing is true. -func TracingTokenMutation(version string, enableTracing bool) secret.Mutation { - return secret.Mutation{ - Name: "tracing-token", - Feature: feature.NewVersionGate(version, nil).When(enableTracing), - Mutate: func(m *secret.Mutator) error { - m.SetStringData("otel-auth-token", "REPLACE_ME") - return nil - }, - } -} - -// MetricsTokenMutation adds a Prometheus remote-write auth token to the Secret. -// It is enabled when enableMetrics is true. -func MetricsTokenMutation(version string, enableMetrics bool) secret.Mutation { - return secret.Mutation{ - Name: "metrics-token", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), - Mutate: func(m *secret.Mutator) error { - m.SetStringData("metrics-auth-token", "REPLACE_ME") - return nil - }, - } -} diff --git a/examples/secret-primitive/main.go b/examples/secret-primitive/main.go deleted file mode 100644 index 0902ef74..00000000 --- a/examples/secret-primitive/main.go +++ /dev/null @@ -1,115 +0,0 @@ -// Package main is the entry point for the secret primitive example. -package main - -import ( - "context" - "fmt" - "os" - - ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" - "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/resources" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func main() { - // 1. Setup scheme and fake client. - scheme := runtime.NewScheme() - if err := sharedapp.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := corev1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add core/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&sharedapp.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &sharedapp.ExampleApp{ - Spec: sharedapp.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - }, - } - owner.Name = "my-example-app" - owner.Namespace = "default" - - if err := fakeClient.Create(context.Background(), owner); err != nil { - fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) - os.Exit(1) - } - - // 3. Initialize the controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - NewSecretResource: resources.NewSecretResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate how - // feature-gated mutations compose secret entries. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - EnableMetrics: true, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: false, // Disable metrics too - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Tracing=%v, Metrics=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics) - - owner.Spec = spec - if err := fakeClient.Update(ctx, owner); err != nil { - fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) - os.Exit(1) - } - - fmt.Println("Running reconciliation...") - if err := controller.Reconcile(ctx, owner); err != nil { - fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) - os.Exit(1) - } - - for _, cond := range owner.Status.Conditions { - fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", - cond.Type, cond.Status, cond.Reason) - } - } - - fmt.Println("\nReconciliation sequence completed successfully!") -} diff --git a/examples/secret-primitive/resources/secret.go b/examples/secret-primitive/resources/secret.go deleted file mode 100644 index eb2d1e06..00000000 --- a/examples/secret-primitive/resources/secret.go +++ /dev/null @@ -1,55 +0,0 @@ -// Package resources provides resource implementations for the secret primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/features" - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// NewSecretResource constructs a secret primitive resource with all the features. -func NewSecretResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base Secret object. - base := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-credentials", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Type: corev1.SecretTypeOpaque, - } - - // 2. Initialize the secret builder. - builder := secret.NewBuilder(base) - - // 3. Register mutations in dependency order. - // - // BaseCredentialsMutation and VersionLabelMutation always run first to establish - // the baseline. Tracing and metrics tokens are then added on top. - builder.WithMutation(features.BaseCredentialsMutation(owner.Spec.Version)) - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.TracingTokenMutation(owner.Spec.Version, owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsTokenMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled Secret (only the persisted Data field is observable). - // - // NOTE: Never log secret values in production controllers. This extractor - // prints only key names and value lengths to avoid leaking credentials. - builder.WithDataExtractor(func(s corev1.Secret) error { - fmt.Printf("Reconciled Secret: %s\n", s.Name) - for key, value := range s.Data { - fmt.Printf(" [%s]: %d bytes\n", key, len(value)) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/service-primitive/README.md b/examples/service-primitive/README.md deleted file mode 100644 index 4f815490..00000000 --- a/examples/service-primitive/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Service Primitive Example - -This example demonstrates the usage of the `service` primitive within the operator component framework. It shows how to -manage a Kubernetes Service as a component of a larger application, utilising features like: - -- **Base Construction**: Initializing a Service with basic metadata, selector, and ports. -- **Feature Mutations**: Applying version-gated or conditional changes (additional ports, labels) using the `Mutator`. -- **Field Flavors**: Preserving annotations that might be managed by external tools (e.g., cloud load balancer - controllers). -- **Operational Status**: Tracking whether the Service is operational (relevant for LoadBalancer types). -- **Suspension**: Demonstrating that, by default, the Service remains present when the component is suspended - (`DeleteOnSuspend=false`), and how to opt into deletion if desired. -- **Data Extraction**: Harvesting information (ClusterIP, ports) from the reconciled resource. - -## 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 and conditional metrics port. -- `resources/`: Contains the central `NewServiceResource` factory that assembles all features using `service.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. - -## Running the Example - -```bash -go run examples/service-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 Service ports after each cycle. -4. Print the resulting status conditions. diff --git a/examples/service-primitive/app/controller.go b/examples/service-primitive/app/controller.go deleted file mode 100644 index d903e715..00000000 --- a/examples/service-primitive/app/controller.go +++ /dev/null @@ -1,55 +0,0 @@ -// Package app provides a sample controller using the service 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 - - // NewServiceResource is a factory function to create the service resource. - // This allows us to inject the resource construction logic. - NewServiceResource 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 service resource for this owner. - svcResource, err := r.NewServiceResource(owner) - if err != nil { - return err - } - - // 2. Build the component that manages the service. - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(svcResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - 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/service-primitive/features/mutations.go b/examples/service-primitive/features/mutations.go deleted file mode 100644 index d040ec0c..00000000 --- a/examples/service-primitive/features/mutations.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package features provides sample mutations for the service 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/service" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/util/intstr" -) - -// VersionLabelMutation sets the app.kubernetes.io/version label on the Service. -// It is always enabled. -func VersionLabelMutation(version string) service.Mutation { - return service.Mutation{ - Name: "version-label", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *service.Mutator) error { - m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { - e.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - return nil - }, - } -} - -// MetricsPortMutation adds a metrics port to the Service when metrics are enabled. -func MetricsPortMutation(version string, enableMetrics bool) service.Mutation { - return service.Mutation{ - Name: "metrics-port", - Feature: feature.NewVersionGate(version, nil).When(enableMetrics), - Mutate: func(m *service.Mutator) error { - m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error { - e.EnsurePort(corev1.ServicePort{ - Name: "metrics", - Port: 9090, - TargetPort: intstr.FromInt32(9090), - Protocol: corev1.ProtocolTCP, - }) - return nil - }) - return nil - }, - } -} diff --git a/examples/service-primitive/main.go b/examples/service-primitive/main.go deleted file mode 100644 index 68c01114..00000000 --- a/examples/service-primitive/main.go +++ /dev/null @@ -1,115 +0,0 @@ -// Package main is the entry point for the service 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/service-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/service-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", - EnableMetrics: true, - Suspended: false, - }, - } - 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, - }, - NewServiceResource: resources.NewServiceResource, - } - - // 4. Run reconciliation with multiple spec versions to demonstrate - // how mutations compose service configuration. - specs := []sharedapp.ExampleAppSpec{ - { - Version: "1.2.3", - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableMetrics: false, // Disable metrics - Suspended: false, - }, - { - Version: "1.2.4", - EnableMetrics: false, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableMetrics, spec.Suspended) - - 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/service-primitive/resources/service.go b/examples/service-primitive/resources/service.go deleted file mode 100644 index 2f4b0f0c..00000000 --- a/examples/service-primitive/resources/service.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package resources provides resource implementations for the service primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/service-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/service" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" -) - -// NewServiceResource constructs a service primitive resource with all the features. -func NewServiceResource(owner *sharedapp.ExampleApp) (component.Resource, error) { - // 1. Create the base Service object. - base := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-service", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{ - "app": owner.Name, - }, - Ports: []corev1.ServicePort{ - { - Name: "http", - Port: 80, - TargetPort: intstr.FromInt32(8080), - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } - - // 2. Initialize the service builder. - builder := service.NewBuilder(base) - - // 3. Register mutations in dependency order. - builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) - builder.WithMutation(features.MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - - // 4. Extract data from the reconciled Service. - builder.WithDataExtractor(func(svc corev1.Service) error { - fmt.Printf("Reconciled Service: %s (ClusterIP: %s)\n", svc.Name, svc.Spec.ClusterIP) - for _, port := range svc.Spec.Ports { - fmt.Printf(" Port: %s %d -> %s\n", port.Name, port.Port, port.TargetPort.String()) - } - return nil - }) - - // 5. Build the final resource. - return builder.Build() -} diff --git a/examples/serviceaccount-primitive/README.md b/examples/serviceaccount-primitive/README.md deleted file mode 100644 index 6de01570..00000000 --- a/examples/serviceaccount-primitive/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index ed3980d4..00000000 --- a/examples/serviceaccount-primitive/app/controller.go +++ /dev/null @@ -1,54 +0,0 @@ -// 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 deleted file mode 100644 index 8ae8b9fd..00000000 --- a/examples/serviceaccount-primitive/features/mutations.go +++ /dev/null @@ -1,64 +0,0 @@ -// 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.NewVersionGate(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.NewVersionGate(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 deleted file mode 100644 index 98f28d9a..00000000 --- a/examples/serviceaccount-primitive/main.go +++ /dev/null @@ -1,115 +0,0 @@ -// 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 deleted file mode 100644 index 3cfa6a5f..00000000 --- a/examples/serviceaccount-primitive/resources/serviceaccount.go +++ /dev/null @@ -1,62 +0,0 @@ -// 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/examples/shared/app/fakeclient.go b/examples/shared/app/fakeclient.go new file mode 100644 index 00000000..0fa6b003 --- /dev/null +++ b/examples/shared/app/fakeclient.go @@ -0,0 +1,40 @@ +package app + +import ( + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// RESTMapperEntry describes a single GVK and its scope for REST mapper setup. +type RESTMapperEntry struct { + GVK schema.GroupVersionKind + Scope meta.RESTScope +} + +// NewFakeClient builds a fake client with a properly configured REST mapper. +// The scheme must already have all required types registered. +func NewFakeClient(scheme *runtime.Scheme, entries []RESTMapperEntry) client.WithWatch { + gvs := make(map[schema.GroupVersion]struct{}) + for _, e := range entries { + gvs[e.GVK.GroupVersion()] = struct{}{} + } + + gvSlice := make([]schema.GroupVersion, 0, len(gvs)) + for gv := range gvs { + gvSlice = append(gvSlice, gv) + } + + mapper := meta.NewDefaultRESTMapper(gvSlice) + for _, e := range entries { + mapper.Add(e.GVK, e.Scope) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithRESTMapper(mapper). + WithStatusSubresource(&ExampleApp{}). + Build() +} diff --git a/examples/shared/app/owner.go b/examples/shared/app/owner.go index d93ea29d..3ef602d0 100644 --- a/examples/shared/app/owner.go +++ b/examples/shared/app/owner.go @@ -18,6 +18,9 @@ type ExampleAppSpec struct { // EnableMetrics adds metrics configuration to the application. EnableMetrics bool `json:"enableMetrics"` + // EnableDebugLogging sets LOG_LEVEL=debug on the application container. + EnableDebugLogging bool `json:"enableDebugLogging"` + // Suspended determines whether the application is active. Suspended bool `json:"suspended"` } diff --git a/examples/statefulset-primitive/README.md b/examples/statefulset-primitive/README.md deleted file mode 100644 index 80682a0e..00000000 --- a/examples/statefulset-primitive/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# StatefulSet Primitive Example - -This example demonstrates the usage of the `statefulset` primitive within the operator component framework. It shows how -to manage a Kubernetes StatefulSet as a component of a larger application, utilizing features like: - -- **Base Construction**: Initializing a StatefulSet with basic metadata, spec, and volume claim templates. -- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the - `Mutator`. -- **Custom Status Handlers**: Overriding the default logic for determining readiness (via `ConvergingStatus` and the - `WithCustomConvergeStatus` builder hook) and health assessment during rollouts (`GraceStatus`). -- **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. -- **Data Extraction**: Harvesting information from the reconciled resource. - -## Directory Structure - -- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. -- `features/`: Contains modular feature definitions: - - `mutations.go`: sidecar injection, env vars, and version-based image updates. - - `handlers.go`: custom status and suspension handlers. -- `resources/`: Contains the central `NewStatefulSetResource` factory that assembles all features using the - `statefulset.Builder`. -- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. - -## Running the Example - -You can run this example directly using `go run`: - -```bash -go run examples/statefulset-primitive/main.go -``` - -This will: - -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile the `ExampleApp` components. -4. Print the resulting status conditions. diff --git a/examples/statefulset-primitive/app/controller.go b/examples/statefulset-primitive/app/controller.go deleted file mode 100644 index 29a7aca9..00000000 --- a/examples/statefulset-primitive/app/controller.go +++ /dev/null @@ -1,50 +0,0 @@ -// Package app provides a sample controller using the statefulset primitive. -package app - -import ( - "context" - - "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 - - // NewStatefulSetResource is a factory function to create the statefulset resource. - NewStatefulSetResource func(*ExampleApp) (component.Resource, error) -} - -// Reconcile performs the reconciliation for a single ExampleApp. -func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { - stsResource, err := r.NewStatefulSetResource(owner) - if err != nil { - return err - } - - comp, err := component.NewComponentBuilder(). - WithName("example-app"). - WithConditionType("AppReady"). - WithResource(stsResource, component.ResourceOptions{}). - Suspend(owner.Spec.Suspended). - Build() - if err != nil { - return err - } - - 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/statefulset-primitive/app/owner.go b/examples/statefulset-primitive/app/owner.go deleted file mode 100644 index 6b611a02..00000000 --- a/examples/statefulset-primitive/app/owner.go +++ /dev/null @@ -1,20 +0,0 @@ -package app - -import ( - sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" -) - -// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. -type ExampleApp = sharedapp.ExampleApp - -// ExampleAppSpec re-exports the shared spec type. -type ExampleAppSpec = sharedapp.ExampleAppSpec - -// ExampleAppStatus re-exports the shared status type. -type ExampleAppStatus = sharedapp.ExampleAppStatus - -// ExampleAppList re-exports the shared list type. -type ExampleAppList = sharedapp.ExampleAppList - -// AddToScheme registers the ExampleApp types with the given scheme. -var AddToScheme = sharedapp.AddToScheme diff --git a/examples/statefulset-primitive/features/handlers.go b/examples/statefulset-primitive/features/handlers.go deleted file mode 100644 index f3a0bdf0..00000000 --- a/examples/statefulset-primitive/features/handlers.go +++ /dev/null @@ -1,63 +0,0 @@ -// Package features provides sample features for the statefulset primitive. -package features - -import ( - "fmt" - "time" - - "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" - appsv1 "k8s.io/api/apps/v1" -) - -// CustomConvergeStatus demonstrates a custom handler for statefulset readiness. -func CustomConvergeStatus() func(concepts.ConvergingOperation, *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { - return func(op concepts.ConvergingOperation, s *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { - status, err := statefulset.DefaultConvergingStatusHandler(op, s) - if err != nil { - return status, err - } - - if status.Status == concepts.AliveConvergingStatusHealthy { - status.Reason = "Application is fully operational and healthy" - } else { - status.Reason = fmt.Sprintf("Application is warming up: %s", status.Reason) - } - - return status, nil - } -} - -// CustomGraceStatus demonstrates a custom handler for health when not ready. -func CustomGraceStatus() func(*appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { - return func(s *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { - if s.Status.ReadyReplicas < 2 { - return concepts.GraceStatusWithReason{ - Status: concepts.GraceStatusDown, - Reason: "At least 2 replicas are required for minimal service", - }, nil - } - - return statefulset.DefaultGraceStatusHandler(s) - } -} - -// CustomSuspendMutation demonstrates a custom mutation when suspended. -func CustomSuspendMutation() func(*statefulset.Mutator) error { - return func(m *statefulset.Mutator) error { - if err := statefulset.DefaultSuspendMutationHandler(m); err != nil { - return err - } - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - raw := meta.Raw() - if _, exists := raw.Annotations["example.io/suspended-at"]; !exists { - meta.EnsureAnnotation("example.io/suspended-at", time.Now().UTC().Format(time.RFC3339)) - } - return nil - }) - - return nil - } -} diff --git a/examples/statefulset-primitive/features/mutations.go b/examples/statefulset-primitive/features/mutations.go deleted file mode 100644 index f12a5020..00000000 --- a/examples/statefulset-primitive/features/mutations.go +++ /dev/null @@ -1,75 +0,0 @@ -package features - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/pkg/feature" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" - corev1 "k8s.io/api/core/v1" -) - -// TracingFeature adds a Jaeger sidecar to the statefulset. -func TracingFeature(enabled bool) statefulset.Mutation { - return statefulset.Mutation{ - Name: "Tracing", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *statefulset.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "JAEGER_AGENT_HOST", - Value: "localhost", - }) - - return nil - }, - } -} - -// MetricsFeature adds an exporter sidecar and some annotations. -func MetricsFeature(enabled bool, port int) statefulset.Mutation { - return statefulset.Mutation{ - Name: "Metrics", - Feature: feature.NewVersionGate("any", nil).When(enabled), - Mutate: func(m *statefulset.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "prometheus-exporter", - Image: "prom/node-exporter:v1.3.1", - }) - - m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureAnnotation("prometheus.io/scrape", "true") - meta.EnsureAnnotation("prometheus.io/port", fmt.Sprintf("%d", port)) - return nil - }) - - return nil - }, - } -} - -// VersionFeature sets the image version and a label. -func VersionFeature(version string) statefulset.Mutation { - return statefulset.Mutation{ - Name: "Version", - Feature: feature.NewVersionGate(version, nil), - Mutate: func(m *statefulset.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-app:%s", version) - return nil - }) - - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - meta.EnsureLabel("app.kubernetes.io/version", version) - return nil - }) - - return nil - }, - } -} diff --git a/examples/statefulset-primitive/main.go b/examples/statefulset-primitive/main.go deleted file mode 100644 index 1f1dce92..00000000 --- a/examples/statefulset-primitive/main.go +++ /dev/null @@ -1,121 +0,0 @@ -// Package main is the entry point for the statefulset 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/statefulset-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/resources" - appsv1 "k8s.io/api/apps/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 for the example. - scheme := runtime.NewScheme() - if err := app.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) - os.Exit(1) - } - if err := appsv1.AddToScheme(scheme); err != nil { - fmt.Fprintf(os.Stderr, "failed to add apps/v1 to scheme: %v\n", err) - os.Exit(1) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&app.ExampleApp{}). - Build() - - // 2. Create an example Owner object. - owner := &app.ExampleApp{ - Spec: app.ExampleAppSpec{ - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - } - 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 our controller. - gauge := ocm.NewOperatorConditionsGauge("example") - controller := &app.ExampleController{ - Client: fakeClient, - Scheme: scheme, - Recorder: record.NewFakeRecorder(100), - Metrics: &ocm.ConditionMetricRecorder{ - Controller: "example-controller", - OperatorConditionsGauge: gauge, - }, - - NewStatefulSetResource: resources.NewStatefulSetResource, - } - - // 4. Run reconciliation with multiple spec versions. - specs := []app.ExampleAppSpec{ - { - Version: "1.2.3", - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", // Version upgrade - EnableTracing: true, - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, // Disable tracing - EnableMetrics: true, - Suspended: false, - }, - { - Version: "1.2.4", - EnableTracing: false, - EnableMetrics: true, - Suspended: true, // Suspend the app - }, - } - - ctx := context.Background() - - for i, spec := range specs { - fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", - i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) - - // Update owner spec - 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) - } - - // Inspect the owner conditions. - 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/statefulset-primitive/resources/statefulset.go b/examples/statefulset-primitive/resources/statefulset.go deleted file mode 100644 index bb7b0268..00000000 --- a/examples/statefulset-primitive/resources/statefulset.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package resources provides resource implementations for the statefulset primitive example. -package resources - -import ( - "fmt" - - "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/app" - "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/features" - "github.com/sourcehawk/operator-component-framework/pkg/component" - "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" -) - -// NewStatefulSetResource constructs a statefulset primitive resource with all the features. -func NewStatefulSetResource(owner *app.ExampleApp) (component.Resource, error) { - // 1. Create the base statefulset object. - base := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: owner.Name + "-statefulset", - Namespace: owner.Namespace, - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: appsv1.StatefulSetSpec{ - ServiceName: owner.Name + "-headless", - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": owner.Name, - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": owner.Name, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "app", - Image: "my-app:latest", // Will be overwritten by VersionFeature - }, - }, - }, - }, - VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ - { - ObjectMeta: metav1.ObjectMeta{Name: "data"}, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("10Gi"), - }, - }, - }, - }, - }, - }, - } - - // 2. Initialize the statefulset builder. - builder := statefulset.NewBuilder(base) - - // 3. Apply mutations (features) based on the owner spec. - builder.WithMutation(features.VersionFeature(owner.Spec.Version)) - builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics, 9090)) - - // 4. Configure custom status handlers. - builder.WithCustomConvergeStatus(features.CustomConvergeStatus()) - builder.WithCustomGraceStatus(features.CustomGraceStatus()) - - // 5. Configure custom suspension logic. - builder.WithCustomSuspendMutation(features.CustomSuspendMutation()) - - // 6. Data extraction (optional). - builder.WithDataExtractor(func(s appsv1.StatefulSet) error { - fmt.Printf("Reconciling statefulset: %s, ready replicas: %d\n", s.Name, s.Status.ReadyReplicas) - - y, err := yaml.Marshal(s) - if err != nil { - return fmt.Errorf("failed to marshal statefulset to yaml: %w", err) - } - fmt.Printf("Complete StatefulSet Resource:\n---\n%s\n---\n", string(y)) - - return nil - }) - - // 7. Build the final resource. - return builder.Build() -} diff --git a/pkg/mutation/selectors/container.go b/pkg/mutation/selectors/container.go index 8c834b38..4dba5168 100644 --- a/pkg/mutation/selectors/container.go +++ b/pkg/mutation/selectors/container.go @@ -36,6 +36,20 @@ func ContainersNamed(names ...string) ContainerSelector { } } +// ContainerNotNamed returns a ContainerSelector that matches all containers except the one with the given name. +func ContainerNotNamed(name string) ContainerSelector { + return func(_ int, c *corev1.Container) bool { + return c.Name != name + } +} + +// ContainersNotNamed returns a ContainerSelector that matches all containers except those with any of the given names. +func ContainersNotNamed(names ...string) ContainerSelector { + return func(_ int, c *corev1.Container) bool { + return !slices.Contains(names, c.Name) + } +} + // ContainerAtIndex returns a ContainerSelector that matches a container at the given index. func ContainerAtIndex(index int) ContainerSelector { return func(i int, _ *corev1.Container) bool { diff --git a/pkg/mutation/selectors/container_test.go b/pkg/mutation/selectors/container_test.go index a07d2ffb..451373c8 100644 --- a/pkg/mutation/selectors/container_test.go +++ b/pkg/mutation/selectors/container_test.go @@ -31,6 +31,24 @@ func TestContainersNamed(t *testing.T) { assert.False(t, ContainersNamed("c1", "c3")(1, c2)) } +func TestContainerNotNamed(t *testing.T) { + c1 := &corev1.Container{Name: "c1"} + c2 := &corev1.Container{Name: "c2"} + + assert.False(t, ContainerNotNamed("c1")(0, c1)) + assert.True(t, ContainerNotNamed("c1")(1, c2)) +} + +func TestContainersNotNamed(t *testing.T) { + c1 := &corev1.Container{Name: "c1"} + c2 := &corev1.Container{Name: "c2"} + c3 := &corev1.Container{Name: "c3"} + + assert.False(t, ContainersNotNamed("c1", "c3")(0, c1)) + assert.True(t, ContainersNotNamed("c1", "c3")(1, c2)) + assert.False(t, ContainersNotNamed("c1", "c3")(2, c3)) +} + func TestContainerAtIndex(t *testing.T) { c1 := &corev1.Container{Name: "c1"} c2 := &corev1.Container{Name: "c2"}