diff --git a/.ai/base.md b/.ai/base.md index 8bada5d1..8084943e 100644 --- a/.ai/base.md +++ b/.ai/base.md @@ -24,6 +24,7 @@ Understand the intended design first: - `docs/primitives.md` — primitive categories, field application, mutation system, editors, selectors - `docs/primitives/*.md` — primitive implementations - `docs/custom-resource.md` — implementing custom resource wrappers using `pkg/generic` +- `docs/cli.md` — the `ocf` scaffolding CLI, its flags, and what the generated code contains - `docs/guidelines.md` — best practices for structuring operators (desired state, one component per condition, etc.) - `docs/compatibility.md` — supported version combinations and compatibility policy @@ -38,6 +39,7 @@ Verify the real API before using or documenting it. Key packages: plus the per-kind `LiftMutation` adapters - `pkg/generic/` — generic building blocks for custom resource wrappers (reconciliation, mutation sequencing, suspension, data extraction) +- `cmd/ocf/` and `internal/scaffold/` — the `ocf` CLI and the wrapper templates it renders - `pkg/mutation/editors/` — available methods per editor type - `pkg/mutation/selectors/` — available container selectors - `pkg/feature/feature.go` — `NewVersionGate`, `Mutation[T]` @@ -98,6 +100,7 @@ Update documentation in the **same response** as the code change — never leave | Primitives, field application, editors, selectors | `docs/primitives.md` | | Primitive implementations | `docs/primitives/*.md` | | Generic building blocks, custom resource wrappers | `docs/custom-resource.md` | +| Wrapper templates, CLI flags | `docs/cli.md` | | Operator structuring patterns, best practices | `docs/guidelines.md` | | Any `pkg/` export visible in the quick start | `README.md` | | Examples | `examples/*/README.md` | diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9359dec3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Generated files. These are collapsed by default in pull request diffs and +# excluded from language statistics. Never edit them directly: change the source +# and re-run the target that produces them. + +# Verbatim copies of docs/, produced by `make sync-plugin`. +plugin/skills/*/references/** linguist-generated=true + +# Produced by `make ai-instructions` from .ai/base.md and .ai/review.md. +.github/copilot-instructions.md linguist-generated=true +.github/copilot-review-guidelines.md linguist-generated=true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fc3ab0b3..70278564 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,7 @@ Understand the intended design first: - `docs/primitives.md` — primitive categories, field application, mutation system, editors, selectors - `docs/primitives/*.md` — primitive implementations - `docs/custom-resource.md` — implementing custom resource wrappers using `pkg/generic` +- `docs/cli.md` — the `ocf` scaffolding CLI, its flags, and what the generated code contains - `docs/guidelines.md` — best practices for structuring operators (desired state, one component per condition, etc.) - `docs/compatibility.md` — supported version combinations and compatibility policy @@ -38,6 +39,7 @@ Verify the real API before using or documenting it. Key packages: plus the per-kind `LiftMutation` adapters - `pkg/generic/` — generic building blocks for custom resource wrappers (reconciliation, mutation sequencing, suspension, data extraction) +- `cmd/ocf/` and `internal/scaffold/` — the `ocf` CLI and the wrapper templates it renders - `pkg/mutation/editors/` — available methods per editor type - `pkg/mutation/selectors/` — available container selectors - `pkg/feature/feature.go` — `NewVersionGate`, `Mutation[T]` @@ -98,6 +100,7 @@ Update documentation in the **same response** as the code change — never leave | Primitives, field application, editors, selectors | `docs/primitives.md` | | Primitive implementations | `docs/primitives/*.md` | | Generic building blocks, custom resource wrappers | `docs/custom-resource.md` | +| Wrapper templates, CLI flags | `docs/cli.md` | | Operator structuring patterns, best practices | `docs/guidelines.md` | | Any `pkg/` export visible in the quick start | `README.md` | | Examples | `examples/*/README.md` | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a0bad39..6d6b2950 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,7 @@ jobs: - name: Running Tests run: | go mod tidy - make test \ No newline at end of file + make test + + - name: Running Scaffold Gate + run: make test-scaffold \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 5fe63024..3455e6d9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,6 +2,8 @@ version: "2" run: timeout: 5m + build-tags: + - scaffold linters: default: none diff --git a/Makefile b/Makefile index 5ae19d22..7cb054cd 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all -all: fmt lint test test-examples build-examples +all: fmt lint test test-scaffold test-examples build-examples ##@ General @@ -147,6 +147,10 @@ lint-go: test: setup-envtest go test -v $(shell go list ./... | grep -v /examples/) -coverprofile cover.out +.PHONY: test-scaffold +test-scaffold: ## Scaffold every wrapper variant into a temp module and run its tests. + go test -tags scaffold -count=1 -run TestScaffoldedWrappers ./internal/scaffold/... + .PHONY: build-examples build-examples: ## Build all example binaries. go build ./examples/... diff --git a/README.md b/README.md index 13d89c7e..12ba7c9c 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,23 @@ go get github.com/sourcehawk/operator-component-framework Requires Go 1.25.6+ and [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) v0.22 or later. +## Scaffolding + +Wrapping a CRD the built-in primitives do not cover is mechanical. The `ocf` CLI generates the whole wrapper package, +compiling and tested, from one command: + +```bash +go install github.com/sourcehawk/operator-component-framework/cmd/ocf@latest + +ocf scaffold wrapper \ + --type github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1.Certificate \ + --variant integration \ + --group cert-manager.io +``` + +See the [CLI guide](https://sourcehawk.github.io/operator-component-framework/cli/) for the full flag set and what to +replace in the generated code. + ## Documentation Full documentation, including a step-by-step tutorial, is at @@ -94,6 +111,7 @@ Full documentation, including a step-by-step tutorial, is at | [Component](https://sourcehawk.github.io/operator-component-framework/component/) | Lifecycle, status model, grace periods, suspension, guards | | [Primitives](https://sourcehawk.github.io/operator-component-framework/primitives/) | Typed wrappers, the mutation system, editors, feature gating | | [Custom Resources](https://sourcehawk.github.io/operator-component-framework/custom-resource/) | Wrap your own CRDs with `pkg/generic` | +| [CLI](https://sourcehawk.github.io/operator-component-framework/cli/) | Scaffold wrapper packages with `ocf scaffold wrapper` | | [Guidelines](https://sourcehawk.github.io/operator-component-framework/guidelines/) | Patterns for structuring operators well | | [Testing](https://sourcehawk.github.io/operator-component-framework/testing/) | Golden snapshots and version-matrix coverage | | [Compatibility](https://sourcehawk.github.io/operator-component-framework/compatibility/) | Supported Kubernetes and controller-runtime versions | diff --git a/cmd/ocf/cli_test.go b/cmd/ocf/cli_test.go new file mode 100644 index 00000000..37ed5025 --- /dev/null +++ b/cmd/ocf/cli_test.go @@ -0,0 +1,217 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "runtime/debug" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runCommand executes the root command with args, capturing stdout and stderr. +func runCommand(t *testing.T, args ...string) (string, error) { + t.Helper() + + root := newRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + + err := root.Execute() + + return out.String(), err +} + +func TestRootCommandListsSubcommands(t *testing.T) { + t.Parallel() + + out, err := runCommand(t, "--help") + require.NoError(t, err) + assert.Contains(t, out, "scaffold") + assert.Contains(t, out, "version") +} + +func TestVersionCommandPrintsVersion(t *testing.T) { + t.Parallel() + + out, err := runCommand(t, "version") + require.NoError(t, err) + assert.NotEmpty(t, out) +} + +func TestScaffoldWrapperGeneratesPackage(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "certificate") + + out, err := runCommand(t, + "scaffold", "wrapper", + "--type", "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1.Certificate", + "--variant", "integration", + "--group", "cert-manager.io", + "--out", dir, + ) + require.NoError(t, err) + + for _, name := range []string{"builder.go", "builder_test.go", "mutator.go", "resource.go"} { + assert.FileExists(t, filepath.Join(dir, name)) + } + + assert.Contains(t, out, dir) + assert.Contains(t, out, "go mod tidy") + assert.Contains(t, out, "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1") + + // dir is absolute (rooted at t.TempDir()), so the printed go test invocation + // must use it as-is, not glued onto a "./" prefix. + assert.Contains(t, out, " 2. Run go test "+dir+"/... to verify the generated package.\n") +} + +func TestScaffoldWrapperDefaultsOutToPackageDirectory(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + out, err := runCommand(t, + "scaffold", "wrapper", + "--type", "k8s.io/api/core/v1.ConfigMap", + "--variant", "static", + "--group", "", + ) + require.NoError(t, err) + assert.FileExists(t, filepath.Join(dir, "configmap", "builder.go")) + + // The default output directory is relative, so the printed go test + // invocation must be a copy-pasteable relative path, prefixed with "./". + assert.Contains(t, out, " 2. Run go test ./configmap/... to verify the generated package.\n") +} + +func TestScaffoldWrapperRefusesNonEmptyDirectoryWithoutForce(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.go"), []byte("package keep\n"), 0o644)) + + _, err := runCommand(t, + "scaffold", "wrapper", + "--type", "k8s.io/api/core/v1.ConfigMap", + "--variant", "static", + "--group", "", + "--out", dir, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not empty") +} + +func TestScaffoldWrapperForceWritesIntoNonEmptyDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.go"), []byte("package keep\n"), 0o644)) + + _, err := runCommand(t, + "scaffold", "wrapper", + "--type", "k8s.io/api/core/v1.ConfigMap", + "--variant", "static", + "--group", "", + "--out", dir, + "--force", + ) + require.NoError(t, err) + assert.FileExists(t, filepath.Join(dir, "builder.go")) + assert.FileExists(t, filepath.Join(dir, "keep.go")) +} + +func TestScaffoldWrapperFlagErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + expectedErr string + }{ + { + name: "missing type", + args: []string{"scaffold", "wrapper", "--variant", "static", "--group", ""}, + expectedErr: "--type is required", + }, + { + name: "missing variant", + args: []string{"scaffold", "wrapper", "--type", "k8s.io/api/core/v1.ConfigMap", "--group", ""}, + expectedErr: "--variant is required", + }, + { + name: "unknown variant", + args: []string{ + "scaffold", "wrapper", + "--type", "k8s.io/api/core/v1.ConfigMap", "--variant", "daemon", "--group", "", + }, + expectedErr: "--variant must be one of static, workload, task, integration", + }, + { + name: "group not provided", + args: []string{"scaffold", "wrapper", "--type", "k8s.io/api/core/v1.ConfigMap", "--variant", "static"}, + expectedErr: "--group is required", + }, + { + name: "version not derivable", + args: []string{ + "scaffold", "wrapper", + "--type", "example.io/apis/messaging.Queue", "--variant", "static", "--group", "messaging.example.io", + }, + expectedErr: "--version is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + args := make([]string, 0, len(tt.args)+2) + args = append(args, tt.args...) + args = append(args, "--out", filepath.Join(t.TempDir(), "pkg")) + _, err := runCommand(t, args...) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + }) + } +} + +func TestVersionFrom(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + info *debug.BuildInfo + ok bool + expected string + }{ + { + name: "build info unavailable", + info: nil, + ok: false, + expected: "unknown", + }, + { + name: "empty main version", + info: &debug.BuildInfo{Main: debug.Module{Version: ""}}, + ok: true, + expected: "unknown", + }, + { + name: "tagged version", + info: &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}, + ok: true, + expected: "v1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, versionFrom(tt.info, tt.ok)) + }) + } +} diff --git a/cmd/ocf/main.go b/cmd/ocf/main.go new file mode 100644 index 00000000..e1f9cdda --- /dev/null +++ b/cmd/ocf/main.go @@ -0,0 +1,10 @@ +// Command ocf generates code for operators built on the operator component framework. +package main + +import "os" + +func main() { + if err := newRootCommand().Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/ocf/root.go b/cmd/ocf/root.go new file mode 100644 index 00000000..7677b99a --- /dev/null +++ b/cmd/ocf/root.go @@ -0,0 +1,19 @@ +package main + +import "github.com/spf13/cobra" + +// newRootCommand builds the ocf command tree. +func newRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "ocf", + Short: "Code generation for the operator component framework", + Long: "ocf generates code for operators built on the operator component framework.\n\n" + + "Templates are embedded in the binary, so generated code always matches the\n" + + "framework version this CLI was built from.", + SilenceUsage: true, + } + + root.AddCommand(newScaffoldCommand(), newVersionCommand()) + + return root +} diff --git a/cmd/ocf/scaffold.go b/cmd/ocf/scaffold.go new file mode 100644 index 00000000..35d340b4 --- /dev/null +++ b/cmd/ocf/scaffold.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/sourcehawk/operator-component-framework/internal/scaffold" + "github.com/spf13/cobra" +) + +// newScaffoldCommand builds the scaffold subcommand group. +func newScaffoldCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "scaffold", + Short: "Generate framework code from embedded templates", + Args: cobra.NoArgs, + } + + cmd.AddCommand(newScaffoldWrapperCommand()) + + return cmd +} + +// newScaffoldWrapperCommand builds the wrapper generation subcommand. +func newScaffoldWrapperCommand() *cobra.Command { + var ( + opts scaffold.Options + out string + force bool + ) + + variantNames := make([]string, 0, len(scaffold.Variants)) + for _, variant := range scaffold.Variants { + variantNames = append(variantNames, string(variant)) + } + + cmd := &cobra.Command{ + Use: "wrapper", + Short: "Generate a custom-resource wrapper package", + Long: "Generate a custom-resource wrapper package for a Kubernetes kind the built-in\n" + + "primitives do not cover. The generated package compiles and its tests pass as\n" + + "soon as the wrapped type resolves in your module.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + opts.GroupSet = cmd.Flags().Changed("group") + + data, err := opts.Resolve() + if err != nil { + return err + } + + dir := out + if dir == "" { + dir = filepath.Join(".", data.Package) + } + + written, err := scaffold.Generate(data, dir, force) + if err != nil { + return err + } + + return printSummary(cmd, data, dir, written) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.Type, "type", "", "wrapped Go type as . (required)") + flags.StringVar(&opts.Variant, "variant", "", + fmt.Sprintf("resource category, one of %s (required)", strings.Join(variantNames, ", "))) + flags.StringVar(&opts.Group, "group", "", `API group, pass "" for core API group types (required)`) + flags.StringVar(&opts.Version, "version", "", "API version, derived from the import path when it ends in one") + flags.StringVar(&opts.Kind, "kind", "", "kind used in the identity string (default: the type name)") + flags.BoolVar(&opts.ClusterScoped, "cluster-scoped", false, "the wrapped kind is cluster-scoped") + flags.StringVar(&opts.Alias, "alias", "", "import alias for the wrapped type's package (default: derived)") + flags.StringVar(&opts.Package, "package", "", "generated Go package name (default: the lowercased kind)") + flags.StringVar(&out, "out", "", "output directory (default: ./)") + flags.BoolVar(&force, "force", false, "write into a non-empty output directory") + + return cmd +} + +// printSummary reports what was generated and what the user has to do next. +func printSummary(cmd *cobra.Command, data scaffold.TemplateData, dir string, written []string) error { + out := cmd.OutOrStdout() + display := displayDir(dir) + + if _, err := fmt.Fprintf(out, "Generated %s wrapper package %q in %s:\n", data.Variant, data.Package, display); err != nil { + return err + } + for _, path := range written { + if _, err := fmt.Fprintf(out, " %s\n", filepath.Base(path)); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(out, "\nNext steps:\n"); err != nil { + return err + } + if _, err := fmt.Fprintf(out, " 1. Run go mod tidy so %s resolves in your module.\n", data.ImportPath); err != nil { + return err + } + if _, err := fmt.Fprintf( + out, " 2. Run go test %s/... to verify the generated package.\n", display, + ); err != nil { + return err + } + _, err := fmt.Fprintf( + out, " 3. Replace the scaffolded default handlers in builder.go with %s-specific logic.\n", data.Kind, + ) + return err +} + +// displayDir formats dir for the summary output as a copy-pasteable path +// argument: an absolute dir is printed as-is, and a relative dir keeps or gains a +// leading "./" so it is recognized as a filesystem path rather than a package +// import path. +func displayDir(dir string) string { + display := filepath.ToSlash(dir) + if filepath.IsAbs(dir) { + return display + } + if strings.HasPrefix(display, "./") || strings.HasPrefix(display, "../") { + return display + } + return "./" + display +} diff --git a/cmd/ocf/version.go b/cmd/ocf/version.go new file mode 100644 index 00000000..a472d8ef --- /dev/null +++ b/cmd/ocf/version.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "runtime/debug" + + "github.com/spf13/cobra" +) + +// newVersionCommand builds the version subcommand. +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the framework version this CLI was built from", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), moduleVersion()) + return err + }, + } +} + +// moduleVersion returns the module version the binary was built from. +func moduleVersion() string { + return versionFrom(debug.ReadBuildInfo()) +} + +// versionFrom reports the main module version recorded in build info, or +// "unknown" when the binary carries no version stamp. +func versionFrom(info *debug.BuildInfo, ok bool) string { + if !ok || info == nil || info.Main.Version == "" { + return "unknown" + } + return info.Main.Version +} diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..eebbbf7e --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,160 @@ +# CLI + +`ocf` generates the custom-resource wrapper pattern that [Custom Resources](custom-resource.md) describes. Templates are +embedded in the binary, so generated code matches the framework version the CLI was built from. + +## Installation + +```bash +go install github.com/sourcehawk/operator-component-framework/cmd/ocf@latest +ocf version +``` + +`ocf version` prints the framework version the binary was built from, so you can confirm which template set a generated +package came from. + +## `ocf scaffold wrapper` + +`ocf scaffold wrapper` generates a custom-resource wrapper package for a Kubernetes kind the built-in primitives do not +cover. + +| Flag | Required | Default | Meaning | +| ------------------ | -------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| `--type` | yes | | Wrapped Go type as `.`, split on the last dot | +| `--variant` | yes | | `static`, `workload`, `task`, or `integration` | +| `--group` | yes | | API group as a DNS subdomain. Pass `--group ""` for core API group types | +| `--version` | no | last import-path segment when it looks like an API version; required otherwise | API version, for example `v1`, `v2beta1` | +| `--kind` | no | the type name | Kind used in the identity string | +| `--cluster-scoped` | no | `false` | Omit the namespace segment and require an empty namespace | +| `--alias` | no | derived | Import alias for the wrapped type's package | +| `--package` | no | lowercased kind | Go package name of the generated package | +| `--out` | no | `./` | Output directory | +| `--force` | no | `false` | Write into a non-empty directory | + +### Group and version validation + +Both values end up verbatim in the generated identity string, so `ocf` checks them before it writes anything. + +`--group` must be a DNS subdomain, the way Kubernetes defines API groups: lowercase letters, digits, `-` and `.`, with +every dot-separated label starting and ending in a letter or digit. `apps`, `cert-manager.io` and +`rbac.authorization.k8s.io` all pass. The one exception is `--group ""`, which selects the core API group and makes the +identity string a bare `//...`. + +`--version` must be a lowercase `v` followed by digits, optionally followed by `alpha` or `beta` and more digits, for +example `v1`, `v2beta1`, or `v1alpha3`. The same check applies whether you pass `--version` yourself or `ocf` derives it +from the import path, so an explicit version and a derived one always mean the same thing. + +A value that fails either check is rejected with an error naming the flag, for example +`--version "1.0" is not a valid API version`, and no files are written. + +### Choosing a variant + +`--variant` selects the generic resource category the wrapper builds on. Each category maps to a different set of +lifecycle interfaces, so the fields your wrapped kind reports readiness through determine which one fits. See +[Choose a resource category](custom-resource.md#1-choose-a-resource-category) for the full explanation. + +| Category | Generic type | Lifecycle interfaces | Use when | +| --------------- | ----------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------ | +| **Workload** | `generic.WorkloadResource` | `Alive`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | Long-running processes with replica-based health | +| **Static** | `generic.StaticResource` | `Guardable`, `DataExtractable` | Configuration objects with no runtime health semantics | +| **Task** | `generic.TaskResource` | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` | Run-to-completion workloads | +| **Integration** | `generic.IntegrationResource` | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | External-dependency objects (services, ingresses) | + +## Worked example + +This scaffolds a wrapper for cert-manager's `Certificate` CRD, an external-dependency object, so it uses the integration +variant: + +```bash +ocf scaffold wrapper \ + --type github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1.Certificate \ + --variant integration \ + --group cert-manager.io +``` + +``` +Generated integration wrapper package "certificate" in ./certificate: + builder.go + builder_test.go + mutator.go + resource.go + +Next steps: + 1. Run go mod tidy so github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 resolves in your module. + 2. Run go test ./certificate/... to verify the generated package. + 3. Replace the scaffolded default handlers in builder.go with Certificate-specific logic. +``` + +Neither `--kind`, `--package`, nor `--version` were passed: the kind defaults to `Certificate` (the type name), the +package defaults to `certificate` (the lowercased kind), and the version defaults to `v1` (the import path's last +segment, which matches the API-version pattern). The four generated files: + +- `builder.go` registers the scaffolded default handlers, exposes the fluent configuration API (`WithMutation`, + `WithGuard`, `WithDataGuard`, `WithOptionalData`, the `WithCustom*` status setters), and `Build()` returns the + `Resource`. +- `builder_test.go` tests `Build()` validation, that a registered mutation applies through the mutator, declared data + extraction with `ExtractInto`, and `WithDataGuard`/`WithOptionalData` gating. +- `mutator.go` defines `Mutator`, which records metadata and object edits and applies them in a single pass when + `Apply()` runs. +- `resource.go` defines `Resource`, which delegates every lifecycle method to the generic base: `Identity`, `Object`, + `Mutate`, the variant's status and suspension methods, `GuardStatus`, `ExtractData`, `ProducedData`, `ConsumedData`, + `RecordObservation`, `Preview`, `RegisteredMutations`, and `FiringSet`. + +Follow the printed next steps: run `go mod tidy` so `github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1` +resolves in your module, then `go test ./certificate/...` to verify the generated package builds and passes before you +start replacing the scaffolded defaults. + +## Import handling + +The import path in `--type` is everything before the last dot, and all four generated files import it, so `ocf` checks +its shape before it writes anything. It must be slash-separated elements of ASCII letters, digits and `-`, `.`, `_`, `~` +or `+`, with no empty element and no element starting or ending in a dot. Anything else is rejected with +`--type import path "..." is not a valid Go import path` and no files are written. Only the shape is checked, so a +well-formed path to a package that does not exist still passes here. + +`--alias` defaults to a derived name when omitted: the sanitized second-to-last import-path segment concatenated with +the last segment, lowercased and with every character that cannot appear in a Go identifier stripped. In the example +above, `github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1` derives `certmanagerv1` from `certmanager` and +`v1`. When the last segment does not look like an API version, the sanitized last segment is used alone. Pass `--alias` +to override the derived name. If no valid Go identifier can be derived at all, `ocf` exits with an error and `--alias` +must be passed explicitly. + +`--version` defaults to the import path's last segment only when that segment matches the API-version pattern described +in [Group and version validation](#group-and-version-validation). When the last segment does not match, for example an +import path ending in `/api` or `/types`, `ocf` exits with `--version is required` and you must pass `--version` +explicitly. + +`ocf` never edits your `go.mod`. It only prints a next-steps block telling you to run `go mod tidy` (or `go get`) if +your module does not already depend on the wrapped type's package. + +## What the generated defaults do + +Which `Default*Handler` functions get generated depends on the variant. Static generates none of them: it has no status, +grace, or suspension semantics, so its builder registers nothing beyond mutation and data-extraction support. + +| Handler | Generated for | Reports unconditionally | +| --------------------------------- | --------------------------- | --------------------------------------------------------- | +| `DefaultConvergingStatusHandler` | Workload, Task | Workload: `Healthy`. Task: `Completed`. | +| `DefaultOperationalStatusHandler` | Integration | `Operational` | +| `DefaultGraceStatusHandler` | Workload, Integration | `Healthy` | +| `DefaultSuspensionStatusHandler` | Workload, Task, Integration | `Suspended` | +| `DefaultSuspendMutationHandler` | Workload, Task, Integration | No mutation; the object is left untouched while suspended | +| `DefaultDeleteOnSuspendHandler` | Workload, Task, Integration | `false`; the object is kept, not deleted | + +Every reason string these handlers return starts with "Scaffolded default", so they are easy to grep for once you start +replacing them. The status handler (`DefaultConvergingStatusHandler` or `DefaultOperationalStatusHandler`) is required +by the generic layer: `Build()` fails without one, so the scaffold registers it in `NewBuilder` and the builder's setter +can only replace it, never clear it. If you replace the status handler, keep the grace handler consistent with it: see +[Keeping convergence and grace consistent](custom-resource.md#keeping-convergence-and-grace-consistent). + +## What the CLI does not check + +`ocf` never loads the target Go package. It does not verify that `--type` refers to a real, exported struct, that the +type satisfies `client.Object`, or that your module depends on the package that defines it. A wrong `--type`, a type +that does not satisfy `client.Object`, or a missing module dependency all surface at your first `go build`. + +## Regenerating + +`ocf scaffold wrapper` refuses to write into a non-empty output directory unless `--force` is passed. With `--force`, it +overwrites the four generated files (`builder.go`, `builder_test.go`, `mutator.go`, `resource.go`) and leaves every +other file in the directory alone. diff --git a/docs/custom-resource.md b/docs/custom-resource.md index 80e97a91..ed36922b 100644 --- a/docs/custom-resource.md +++ b/docs/custom-resource.md @@ -18,6 +18,12 @@ status, and mutator logic, exactly the way the built-in primitives do. (`pkg/primitives/unstructured/static`). See [Unstructured Primitives](primitives.md#unstructured-primitives). This guide covers the wrapper pattern, which gives you a typed, self-documenting API for a kind you manage often. +!!! tip "Generate this pattern" + + `ocf scaffold wrapper` generates the complete package this page describes: mutator, builder, resource, and tests, + compiling and passing on a fresh scaffold. See the [CLI](cli.md) guide. This page stays the reference for what the + generated code means and what to replace in it. + --- ## Steps diff --git a/go.mod b/go.mod index 3ad7991d..aeebf621 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/onsi/gomega v1.42.1 github.com/pmezard/go-difflib v1.0.0 github.com/sourcehawk/go-crd-condition-metrics v1.1.0 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 k8s.io/api v0.35.2 k8s.io/apiextensions-apiserver v0.35.0 @@ -37,6 +38,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index 3aa3fbb0..548463a1 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,10 @@ -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -51,12 +50,12 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -88,16 +87,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= -github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= -github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -114,10 +105,13 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sourcehawk/go-crd-condition-metrics v1.1.0 h1:wV+vSyCCxjIdhBjZENzzoGejuTVJ+bCNkDv+58M56fM= github.com/sourcehawk/go-crd-condition-metrics v1.1.0/go.mod h1:hzmkaQhFMu4Mqylzu4HGO/dN8rsSEmI6/ivwlchTVrw= github.com/sourcehawk/go-prometheus-gaugevecset v1.1.0 h1:M0R2IZrKYT9dvJ+2bnPb69UVfFzHmS1pkBNbRTbXkM4= github.com/sourcehawk/go-prometheus-gaugevecset v1.1.0/go.mod h1:lNJLSekPoA5pzxuyNMNf5XrE5Kvbaqe3kZ4Fe06R1rg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -151,50 +145,22 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= @@ -223,10 +189,6 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= -k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= -k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= diff --git a/internal/scaffold/data.go b/internal/scaffold/data.go new file mode 100644 index 00000000..e9aaa859 --- /dev/null +++ b/internal/scaffold/data.go @@ -0,0 +1,68 @@ +package scaffold + +import "fmt" + +// TemplateData is the fully resolved input to the wrapper templates. Every field +// is validated or derived by Options.Resolve. +type TemplateData struct { + // Package is the Go package name of the generated package. + Package string + // ImportPath is the wrapped type's import path. + ImportPath string + // ImportAlias is the alias the generated files import ImportPath under. + ImportAlias string + // TypeName is the wrapped Go type's name. + TypeName string + // Group is the API group, empty for core types. + Group string + // Version is the API version. + Version string + // Kind is the kind used in identities and documentation. + Kind string + // ClusterScoped reports whether the wrapped kind is cluster-scoped. + ClusterScoped bool + // Variant is the resource category the wrapper belongs to. + Variant Variant +} + +// Spec returns the generic-layer wiring for the data's variant. +func (d TemplateData) Spec() VariantSpec { + return d.Variant.Spec() +} + +// QualifiedType returns the wrapped type qualified by its import alias. +func (d TemplateData) QualifiedType() string { + return fmt.Sprintf("%s.%s", d.ImportAlias, d.TypeName) +} + +// PointerType returns a pointer to the wrapped type qualified by its import alias. +func (d TemplateData) PointerType() string { + return "*" + d.QualifiedType() +} + +// APIVersion returns "/", or bare "" for core types. +func (d TemplateData) APIVersion() string { + if d.Group == "" { + return d.Version + } + return d.Group + "/" + d.Version +} + +// IdentityFormat returns the fmt format string for the resource identity, +// following the framework convention "///" +// and omitting the namespace segment for cluster-scoped kinds. +func (d TemplateData) IdentityFormat() string { + if d.ClusterScoped { + return fmt.Sprintf("%s/%s/%%s", d.APIVersion(), d.Kind) + } + return fmt.Sprintf("%s/%s/%%s/%%s", d.APIVersion(), d.Kind) +} + +// IdentityArgs returns the fmt arguments matching IdentityFormat, expressed +// against the identity function's parameter named o. +func (d TemplateData) IdentityArgs() string { + if d.ClusterScoped { + return "o.Name" + } + return "o.Namespace, o.Name" +} diff --git a/internal/scaffold/gate_test.go b/internal/scaffold/gate_test.go new file mode 100644 index 00000000..a41a6f02 --- /dev/null +++ b/internal/scaffold/gate_test.go @@ -0,0 +1,212 @@ +//go:build scaffold + +package scaffold_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/sourcehawk/operator-component-framework/internal/scaffold" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// gateCases scaffolds one wrapper per variant against a real Kubernetes type. +func gateCases() []scaffold.Options { + return []scaffold.Options{ + {Type: "k8s.io/api/core/v1.ConfigMap", Variant: "static", Group: "", GroupSet: true}, + {Type: "k8s.io/api/apps/v1.Deployment", Variant: "workload", Group: "apps", GroupSet: true}, + {Type: "k8s.io/api/batch/v1.Job", Variant: "task", Group: "batch", GroupSet: true}, + { + Type: "k8s.io/api/networking/v1.Ingress", Variant: "integration", + Group: "networking.k8s.io", GroupSet: true, + }, + { + Type: "k8s.io/api/rbac/v1.ClusterRole", Variant: "static", + Group: "rbac.authorization.k8s.io", GroupSet: true, ClusterScoped: true, + }, + } +} + +// gateModuleName is the module declared in the temporary go.mod written by +// writeGateModule. Generated package import paths are this name joined with +// the package's directory relative to the module root. +const gateModuleName = "ocfscaffoldgate" + +// TestScaffoldedWrappersCompileAndPass generates every variant into a temporary +// module that replaces the framework with this checkout, then runs the generated +// tests inside it. +// +// Passing the temp module's overall exit code is not enough: a Go test binary +// exits 0 both for a package with zero test files ("[no test files]") and for a +// test file with zero Test functions ("[no tests to run]"). Either would let a +// template regression that guts or omits builder_test.go sail through silently. +// So this test also asserts, per generated package, that at least one test +// actually reported a pass, using `go test -json` output rather than scraping +// text markers. +func TestScaffoldedWrappersCompileAndPass(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + + moduleDir := t.TempDir() + writeGateModule(t, repoRoot, moduleDir) + + expectedPackages := make([]string, 0, len(gateCases())) + for _, opts := range gateCases() { + data, err := opts.Resolve() + require.NoError(t, err) + + outDir := filepath.Join(moduleDir, data.Package) + written, err := scaffold.Generate(data, outDir, false) + require.NoError(t, err, "generate %s", data.Package) + + expectedFiles := make([]string, 0, len(scaffold.GeneratedFiles)) + for _, name := range scaffold.GeneratedFiles { + expectedFiles = append(expectedFiles, filepath.Join(outDir, name)) + } + assert.Equal(t, expectedFiles, written, "generate %s did not produce the expected file set", data.Package) + + expectedPackages = append(expectedPackages, gateModuleName+"/"+data.Package) + } + + events, out, err := runGoTestJSON(t, moduleDir) + require.NoError(t, err, "go test in scaffolded module failed:\n%s", out) + + assertEachPackageRanTests(t, events, expectedPackages) +} + +// testEvent is the subset of a `go test -json` event this gate needs. The full +// event carries Time and Elapsed fields too, which are unused here. +type testEvent struct { + Action string `json:"Action"` + Package string `json:"Package"` + Test string `json:"Test"` +} + +// assertEachPackageRanTests fails the test if any expected package reported +// zero passing tests. A package with no test files, or a test file with no +// Test functions, both produce zero "pass" events for that package, so this +// catches template regressions the exit code alone would miss. +func assertEachPackageRanTests(t *testing.T, events []testEvent, expectedPackages []string) { + t.Helper() + + passed := make(map[string]int) + for _, ev := range events { + if ev.Action == "pass" && ev.Test != "" { + passed[ev.Package]++ + } + } + + for _, pkg := range expectedPackages { + assert.Positive(t, passed[pkg], "package %q reported no passing tests; its test file may be missing or empty", pkg) + } +} + +// writeGateModule writes a go.mod that replaces the framework with the local +// checkout, pinning every direct dependency to the version this module uses. +func writeGateModule(t *testing.T, repoRoot, moduleDir string) { + t.Helper() + + goVersion := strings.TrimSpace(mustRunGo(t, repoRoot, "list", "-m", "-f", "{{.GoVersion}}")) + + var requires strings.Builder + for _, path := range []string{ + "k8s.io/api", + "k8s.io/apimachinery", + "sigs.k8s.io/controller-runtime", + "github.com/stretchr/testify", + } { + version := strings.TrimSpace(mustRunGo(t, repoRoot, "list", "-m", "-f", "{{.Version}}", path)) + requires.WriteString("\t" + path + " " + version + "\n") + } + + goMod := "module " + gateModuleName + "\n\n" + + "go " + goVersion + "\n\n" + + "require (\n" + + "\tgithub.com/sourcehawk/operator-component-framework v0.0.0\n" + + requires.String() + + ")\n\n" + + "replace github.com/sourcehawk/operator-component-framework => " + repoRoot + "\n" + + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0o600)) + + goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.sum"), goSum, 0o600)) +} + +// runGo runs the go tool in dir and returns its combined output. +func runGo(t *testing.T, dir string, args ...string) (string, error) { + t.Helper() + + cmd := exec.CommandContext(t.Context(), "go", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + + out, err := cmd.CombinedOutput() + + return string(out), err +} + +// mustRunGo runs the go tool in dir and fails the test if it errors. +func mustRunGo(t *testing.T, dir string, args ...string) string { + t.Helper() + + out, err := runGo(t, dir, args...) + require.NoError(t, err, "go %s failed:\n%s", strings.Join(args, " "), out) + + return out +} + +// runGoTestJSON runs `go test -json ./...` in dir. It returns the decoded test +// events from stdout, plus the combined stdout and stderr for use in failure +// messages, so that a failing run still has readable diagnostics. +// +// Stdout and stderr are captured separately, not combined, because `go test +// -json` writes newline-delimited JSON only to stdout; interleaving it with +// stderr on the same buffer could split a JSON line mid-write and break decoding. +func runGoTestJSON(t *testing.T, dir string) ([]testEvent, string, error) { + t.Helper() + + // -count=1 disables the test cache so the gate genuinely executes the + // generated tests on every run rather than replaying a previous result. + cmd := exec.CommandContext(t.Context(), "go", "test", "-count=1", "-json", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + + rawStdout := stdout.String() + + var events []testEvent + decoder := json.NewDecoder(strings.NewReader(rawStdout)) + for { + var ev testEvent + decodeErr := decoder.Decode(&ev) + if errors.Is(decodeErr, io.EOF) { + break + } + if decodeErr != nil { + // Anything other than EOF means the event stream is truncated or + // corrupted. Report it rather than silently dropping the remaining + // events, which would surface later as a package that appears to + // have run no tests. + return events, rawStdout + stderr.String(), + errors.Join(runErr, fmt.Errorf("decode go test -json output: %w", decodeErr)) + } + events = append(events, ev) + } + + return events, rawStdout + stderr.String(), runErr +} diff --git a/internal/scaffold/generate.go b/internal/scaffold/generate.go new file mode 100644 index 00000000..e507b1e8 --- /dev/null +++ b/internal/scaffold/generate.go @@ -0,0 +1,71 @@ +package scaffold + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// Generate renders the wrapper package described by data and writes it to +// outDir, returning the written file paths. +// +// The directory is created when missing. An existing directory that already +// contains entries is refused unless force is true, so the CLI never overwrites +// silently. +func Generate(data TemplateData, outDir string, force bool) ([]string, error) { + files, err := Render(data) + if err != nil { + return nil, err + } + + if err := checkOutputDir(outDir, force); err != nil { + return nil, err + } + + if err := os.MkdirAll(outDir, 0o755); err != nil { + return nil, fmt.Errorf("create output directory %q: %w", outDir, err) + } + + written := make([]string, 0, len(GeneratedFiles)) + for _, name := range GeneratedFiles { + path := filepath.Join(outDir, name) + if err := os.WriteFile(path, files[name], 0o644); err != nil { + return nil, fmt.Errorf("write %q: %w", path, err) + } + written = append(written, path) + } + + return written, nil +} + +// checkOutputDir verifies that outDir is usable as an output directory. +func checkOutputDir(outDir string, force bool) error { + info, err := os.Stat(outDir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("inspect output directory %q: %w", outDir, err) + } + + if !info.IsDir() { + return fmt.Errorf("output path %q is not a directory", outDir) + } + + if force { + return nil + } + + entries, err := os.ReadDir(outDir) + if err != nil { + return fmt.Errorf("read output directory %q: %w", outDir, err) + } + + if len(entries) > 0 { + return fmt.Errorf("output directory %q is not empty, pass --force to write into it", outDir) + } + + return nil +} diff --git a/internal/scaffold/generate_test.go b/internal/scaffold/generate_test.go new file mode 100644 index 00000000..a6edde7c --- /dev/null +++ b/internal/scaffold/generate_test.go @@ -0,0 +1,80 @@ +package scaffold + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func staticData() TemplateData { + return TemplateData{ + Package: "configmap", ImportPath: "k8s.io/api/core/v1", ImportAlias: "corev1", + TypeName: "ConfigMap", Version: "v1", Kind: "ConfigMap", Variant: VariantStatic, + } +} + +func TestGenerateWritesAllFiles(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "configmap") + + written, err := Generate(staticData(), dir, false) + require.NoError(t, err) + require.Len(t, written, len(GeneratedFiles)) + + for _, name := range GeneratedFiles { + content, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + assert.NotEmpty(t, content) + } +} + +func TestGenerateRefusesNonEmptyDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "existing.go"), []byte("package x\n"), 0o644)) + + _, err := Generate(staticData(), dir, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not empty") + assert.Contains(t, err.Error(), "--force") +} + +func TestGenerateAllowsEmptyExistingDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + _, err := Generate(staticData(), dir, false) + require.NoError(t, err) +} + +func TestGenerateForceOverwrites(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + builderPath := filepath.Join(dir, "builder.go") + require.NoError(t, os.WriteFile(builderPath, []byte("package stale\n"), 0o644)) + + _, err := Generate(staticData(), dir, true) + require.NoError(t, err) + + content, err := os.ReadFile(builderPath) + require.NoError(t, err) + assert.Contains(t, string(content), "package configmap") +} + +func TestGenerateRejectsFilePath(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "afile") + require.NoError(t, os.WriteFile(path, []byte("x"), 0o644)) + + _, err := Generate(staticData(), path, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a directory") +} diff --git a/internal/scaffold/options.go b/internal/scaffold/options.go new file mode 100644 index 00000000..31a0ab93 --- /dev/null +++ b/internal/scaffold/options.go @@ -0,0 +1,240 @@ +package scaffold + +import ( + "fmt" + "go/token" + "regexp" + "strings" +) + +var ( + apiVersionPattern = regexp.MustCompile(`^v[0-9]+((alpha|beta)[0-9]+)?$`) + apiGroupPattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + importPathElementPattern = regexp.MustCompile(`^[A-Za-z0-9_~+.-]+$`) + exportedNamePattern = regexp.MustCompile(`^[A-Z][A-Za-z0-9_]*$`) + packageNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + identifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]`) +) + +// Options are the raw flag values of "ocf scaffold wrapper" before validation +// and defaulting. +type Options struct { + // Type is the wrapped Go type as ".". + Type string + // Variant is the resource category name. + Variant string + // Group is the API group. An empty Group is valid for core types, so + // GroupSet records whether the flag was provided at all. + Group string + // GroupSet reports whether --group was provided. + GroupSet bool + // Version is the API version. Derived from Type when empty. + Version string + // Kind is the kind used in the identity string. Defaults to the type name. + Kind string + // Alias is the import alias for the wrapped type's package. Derived when empty. + Alias string + // Package is the generated Go package name. Defaults to the lowercased kind. + Package string + // ClusterScoped marks the wrapped kind as cluster-scoped. + ClusterScoped bool +} + +// Resolve validates the options and derives every unset value, returning the +// data the templates render from. +func (o Options) Resolve() (TemplateData, error) { + importPath, typeName, err := splitType(o.Type) + if err != nil { + return TemplateData{}, err + } + + variant, err := parseVariant(o.Variant) + if err != nil { + return TemplateData{}, err + } + + if !o.GroupSet { + return TemplateData{}, fmt.Errorf(`--group is required (pass --group "" for core API group types)`) + } + if o.Group != "" && !apiGroupPattern.MatchString(o.Group) { + return TemplateData{}, fmt.Errorf( + `--group %q is not a valid API group (a DNS subdomain, or "" for core API group types)`, o.Group, + ) + } + + version := o.Version + if version == "" { + lastSegment := lastPathSegment(importPath) + if !apiVersionPattern.MatchString(lastSegment) { + return TemplateData{}, fmt.Errorf( + "--version is required: the last segment %q of the import path is not an API version", + lastSegment, + ) + } + version = lastSegment + } else if !apiVersionPattern.MatchString(version) { + return TemplateData{}, fmt.Errorf("--version %q is not a valid API version", version) + } + + kind := o.Kind + if kind == "" { + kind = typeName + } + if !exportedNamePattern.MatchString(kind) { + return TemplateData{}, fmt.Errorf("--kind %q must be an exported Go identifier", kind) + } + + alias := o.Alias + if alias == "" { + alias = deriveAlias(importPath) + if alias == "" { + return TemplateData{}, fmt.Errorf( + "--alias is required: an import alias cannot be derived from import path %q", importPath, + ) + } + } + if !identifierPattern.MatchString(alias) || token.Lookup(alias).IsKeyword() { + return TemplateData{}, fmt.Errorf("--alias %q is not a valid Go identifier", alias) + } + + pkg := o.Package + if pkg == "" { + pkg = strings.ToLower(kind) + } + if !packageNamePattern.MatchString(pkg) { + return TemplateData{}, fmt.Errorf("--package %q is not a valid Go package name", pkg) + } + if token.Lookup(pkg).IsKeyword() { + return TemplateData{}, fmt.Errorf("--package %q is a Go keyword", pkg) + } + + return TemplateData{ + Package: pkg, + ImportPath: importPath, + ImportAlias: alias, + TypeName: typeName, + Group: o.Group, + Version: version, + Kind: kind, + ClusterScoped: o.ClusterScoped, + Variant: variant, + }, nil +} + +// splitType splits "." on the last dot in its final +// path segment. Dots earlier in the import path, such as the domain in +// "k8s.io/api/apps/v1.Deployment", are not treated as the separator. +func splitType(value string) (importPath, typeName string, err error) { + if value == "" { + return "", "", fmt.Errorf("--type is required") + } + + slashIdx := strings.LastIndex(value, "/") + idx := strings.LastIndex(value, ".") + if idx < 0 || idx < slashIdx { + return "", "", fmt.Errorf("--type must be ., got %q", value) + } + + importPath, typeName = value[:idx], value[idx+1:] + if importPath == "" { + return "", "", fmt.Errorf("--type is missing an import path, got %q", value) + } + if typeName == "" { + return "", "", fmt.Errorf("--type must be ., got %q", value) + } + if !validImportPath(importPath) { + return "", "", fmt.Errorf("--type import path %q is not a valid Go import path", importPath) + } + if !exportedNamePattern.MatchString(typeName) { + return "", "", fmt.Errorf("--type type name %q must be an exported Go identifier", typeName) + } + + return importPath, typeName, nil +} + +// validImportPath reports whether path is shaped like a Go import path: one or +// more slash-separated elements, each non-empty, built only from the ASCII +// letters, digits and "-._~+" the module system allows in a path element, and +// neither starting nor ending in a dot. It checks the shape of the path only, +// the way the go command does before it ever looks at a module cache, so an +// import path that no module provides still passes and only a path no package +// could ever have is rejected. +func validImportPath(path string) bool { + if path == "" { + return false + } + + for _, element := range strings.Split(path, "/") { + if !importPathElementPattern.MatchString(element) { + return false + } + if strings.HasPrefix(element, ".") || strings.HasSuffix(element, ".") { + return false + } + } + + return true +} + +// parseVariant maps the flag value to a Variant. +func parseVariant(value string) (Variant, error) { + if value == "" { + return "", fmt.Errorf("--variant is required") + } + + for _, variant := range Variants { + if Variant(value) == variant { + return variant, nil + } + } + + names := make([]string, 0, len(Variants)) + for _, variant := range Variants { + names = append(names, string(variant)) + } + + return "", fmt.Errorf("--variant must be one of %s; got %q", strings.Join(names, ", "), value) +} + +// lastPathSegment returns the final slash-separated segment of an import path. +func lastPathSegment(importPath string) string { + if idx := strings.LastIndex(importPath, "/"); idx >= 0 { + return importPath[idx+1:] + } + return importPath +} + +// deriveAlias builds an import alias following the Kubernetes ecosystem +// convention (corev1, appsv1, certmanagerv1): the sanitized second-to-last path +// segment concatenated with a version-like last segment. When the last segment +// is not an API version, the sanitized last segment is used alone. It returns an +// empty string when no valid identifier can be derived. +func deriveAlias(importPath string) string { + segments := strings.Split(importPath, "/") + last := sanitizeSegment(segments[len(segments)-1]) + + if !apiVersionPattern.MatchString(segments[len(segments)-1]) { + return validAliasOrEmpty(last) + } + + if len(segments) < 2 { + return validAliasOrEmpty(last) + } + + return validAliasOrEmpty(sanitizeSegment(segments[len(segments)-2]) + last) +} + +// sanitizeSegment lowercases a path segment and strips every character that +// cannot appear in a Go identifier. +func sanitizeSegment(segment string) string { + return nonAlphanumeric.ReplaceAllString(strings.ToLower(segment), "") +} + +// validAliasOrEmpty returns alias when it is a usable Go identifier. +func validAliasOrEmpty(alias string) string { + if alias == "" || !identifierPattern.MatchString(alias) || token.Lookup(alias).IsKeyword() { + return "" + } + return alias +} diff --git a/internal/scaffold/options_test.go b/internal/scaffold/options_test.go new file mode 100644 index 00000000..4809d91a --- /dev/null +++ b/internal/scaffold/options_test.go @@ -0,0 +1,362 @@ +package scaffold + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func validOptions() Options { + return Options{ + Type: "k8s.io/api/apps/v1.Deployment", + Variant: "workload", + Group: "apps", + GroupSet: true, + } +} + +func TestResolveDerivesDefaults(t *testing.T) { + t.Parallel() + + data, err := validOptions().Resolve() + require.NoError(t, err) + + assert.Equal(t, "k8s.io/api/apps/v1", data.ImportPath) + assert.Equal(t, "appsv1", data.ImportAlias) + assert.Equal(t, "Deployment", data.TypeName) + assert.Equal(t, "Deployment", data.Kind) + assert.Equal(t, "v1", data.Version) + assert.Equal(t, "apps", data.Group) + assert.Equal(t, "deployment", data.Package) + assert.Equal(t, VariantWorkload, data.Variant) + assert.False(t, data.ClusterScoped) +} + +func TestResolveDerivations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Options) + expectedAlias string + expectedVer string + expectedPkg string + expectedKind string + }{ + { + name: "core group type", + mutate: func(o *Options) { o.Type = "k8s.io/api/core/v1.ConfigMap"; o.Group = ""; o.Variant = "static" }, + expectedAlias: "corev1", + expectedVer: "v1", + expectedPkg: "configmap", + expectedKind: "ConfigMap", + }, + { + name: "third party crd with dashed segment", + mutate: func(o *Options) { + o.Type = "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1.Certificate" + o.Group = "cert-manager.io" + }, + expectedAlias: "certmanagerv1", + expectedVer: "v1", + expectedPkg: "certificate", + expectedKind: "Certificate", + }, + { + name: "beta version segment", + mutate: func(o *Options) { + o.Type = "example.io/api/messaging/v1beta2.Queue" + o.Group = "messaging.example.io" + }, + expectedAlias: "messagingv1beta2", + expectedVer: "v1beta2", + expectedPkg: "queue", + expectedKind: "Queue", + }, + { + name: "multi segment import path with dashes and underscores", + mutate: func(o *Options) { + o.Type = "example.io/go-api/v2_x/messaging/v1beta2.Queue" + o.Group = "messaging.example.io" + }, + expectedAlias: "messagingv1beta2", + expectedVer: "v1beta2", + expectedPkg: "queue", + expectedKind: "Queue", + }, + { + name: "explicit overrides win", + mutate: func(o *Options) { + o.Alias = "customalias" + o.Version = "v2" + o.Package = "mypkg" + o.Kind = "OtherKind" + }, + expectedAlias: "customalias", + expectedVer: "v2", + expectedPkg: "mypkg", + expectedKind: "OtherKind", + }, + { + name: "non version last segment derives alias from that segment", + mutate: func(o *Options) { + o.Type = "example.io/apis/messaging.Queue" + o.Version = "v1" + o.Group = "messaging.example.io" + }, + expectedAlias: "messaging", + expectedVer: "v1", + expectedPkg: "queue", + expectedKind: "Queue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + opts := validOptions() + tt.mutate(&opts) + + data, err := opts.Resolve() + require.NoError(t, err) + assert.Equal(t, tt.expectedAlias, data.ImportAlias) + assert.Equal(t, tt.expectedVer, data.Version) + assert.Equal(t, tt.expectedPkg, data.Package) + assert.Equal(t, tt.expectedKind, data.Kind) + }) + } +} + +func TestResolveAcceptsValidGroupsAndVersions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Options) + expectedGroup string + expectedVersion string + }{ + { + name: "non core group", + mutate: func(o *Options) { o.Group = "rbac.authorization.k8s.io" }, + expectedGroup: "rbac.authorization.k8s.io", + expectedVersion: "v1", + }, + { + name: "dashed group label", + mutate: func(o *Options) { o.Group = "cert-manager.io" }, + expectedGroup: "cert-manager.io", + expectedVersion: "v1", + }, + { + name: "empty group is the core API group", + mutate: func(o *Options) { o.Group = "" }, + expectedGroup: "", + expectedVersion: "v1", + }, + { + name: "explicit version", + mutate: func(o *Options) { o.Version = "v2beta1" }, + expectedGroup: "apps", + expectedVersion: "v2beta1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + opts := validOptions() + tt.mutate(&opts) + + data, err := opts.Resolve() + require.NoError(t, err) + assert.Equal(t, tt.expectedGroup, data.Group) + assert.Equal(t, tt.expectedVersion, data.Version) + }) + } +} + +func TestResolveValidationErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Options) + expectedErr string + }{ + { + name: "missing type", + mutate: func(o *Options) { o.Type = "" }, + expectedErr: "--type is required", + }, + { + name: "type without dot", + mutate: func(o *Options) { o.Type = "k8s.io/api/apps/v1" }, + expectedErr: `--type must be .`, + }, + { + name: "unexported type name", + mutate: func(o *Options) { o.Type = "k8s.io/api/apps/v1.deployment" }, + expectedErr: `type name "deployment" must be an exported Go identifier`, + }, + { + name: "empty import path", + mutate: func(o *Options) { o.Type = ".Deployment" }, + expectedErr: "--type is missing an import path", + }, + { + name: "import path with a Go import break out", + mutate: func(o *Options) { o.Type = "k8s.io/api/core/v1\"\n\t_ \"os.ConfigMap" }, + expectedErr: "--type import path \"k8s.io/api/core/v1\\\"\\n\\t_ \\\"os\" " + + "is not a valid Go import path", + }, + { + name: "import path with a space", + mutate: func(o *Options) { o.Type = "k8s.io/api/core v1.ConfigMap" }, + expectedErr: `--type import path "k8s.io/api/core v1" is not a valid Go import path`, + }, + { + name: "import path with an empty element", + mutate: func(o *Options) { o.Type = "example.io//v1.Queue" }, + expectedErr: `--type import path "example.io//v1" is not a valid Go import path`, + }, + { + name: "missing variant", + mutate: func(o *Options) { o.Variant = "" }, + expectedErr: "--variant is required", + }, + { + name: "unknown variant", + mutate: func(o *Options) { o.Variant = "daemon" }, + expectedErr: `--variant must be one of static, workload, task, integration; got "daemon"`, + }, + { + name: "group not provided", + mutate: func(o *Options) { o.Group = ""; o.GroupSet = false }, + expectedErr: `--group is required (pass --group "" for core API group types)`, + }, + { + name: "version not derivable", + mutate: func(o *Options) { o.Type = "example.io/apis/messaging.Queue" }, + expectedErr: `--version is required: the last segment "messaging" of the import path is not an API version`, + }, + { + name: "uppercase group", + mutate: func(o *Options) { o.Group = "Apps" }, + expectedErr: `--group "Apps" is not a valid API group (a DNS subdomain, or "" for core API group types)`, + }, + { + name: "group label ends with a dash", + mutate: func(o *Options) { o.Group = "apps-.io" }, + expectedErr: `--group "apps-.io" is not a valid API group`, + }, + { + name: "group with a Go string break out", + mutate: func(o *Options) { o.Group = `a"+os.Getenv("X")+"b` }, + expectedErr: `--group "a\"+os.Getenv(\"X\")+\"b" is not a valid API group`, + }, + { + name: "explicit version is not an API version", + mutate: func(o *Options) { o.Version = "1.0" }, + expectedErr: `--version "1.0" is not a valid API version`, + }, + { + name: "invalid package name", + mutate: func(o *Options) { o.Package = "My-Package" }, + expectedErr: `--package "My-Package" is not a valid Go package name`, + }, + { + name: "reserved package name", + mutate: func(o *Options) { o.Package = "func" }, + expectedErr: `--package "func" is a Go keyword`, + }, + { + name: "invalid alias", + mutate: func(o *Options) { o.Alias = "apps/v1" }, + expectedErr: `--alias "apps/v1" is not a valid Go identifier`, + }, + { + name: "invalid kind", + mutate: func(o *Options) { o.Kind = "my kind" }, + expectedErr: `--kind "my kind" must be an exported Go identifier`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + opts := validOptions() + tt.mutate(&opts) + + _, err := opts.Resolve() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + }) + } +} + +func TestTemplateDataIdentity(t *testing.T) { + t.Parallel() + + namespaced := TemplateData{Group: "apps", Version: "v1", Kind: "Deployment"} + assert.Equal(t, "apps/v1", namespaced.APIVersion()) + assert.Equal(t, "apps/v1/Deployment/%s/%s", namespaced.IdentityFormat()) + assert.Equal(t, "o.Namespace, o.Name", namespaced.IdentityArgs()) + + core := TemplateData{Group: "", Version: "v1", Kind: "ConfigMap"} + assert.Equal(t, "v1", core.APIVersion()) + assert.Equal(t, "v1/ConfigMap/%s/%s", core.IdentityFormat()) + + clusterScoped := TemplateData{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole", ClusterScoped: true} + assert.Equal(t, "rbac.authorization.k8s.io/v1/ClusterRole/%s", clusterScoped.IdentityFormat()) + assert.Equal(t, "o.Name", clusterScoped.IdentityArgs()) +} + +func TestTemplateDataTypeNames(t *testing.T) { + t.Parallel() + + data := TemplateData{ImportAlias: "appsv1", TypeName: "Deployment"} + assert.Equal(t, "appsv1.Deployment", data.QualifiedType()) + assert.Equal(t, "*appsv1.Deployment", data.PointerType()) +} + +func TestVariantSpecs(t *testing.T) { + t.Parallel() + + static := VariantStatic.Spec() + assert.Equal(t, "StaticBuilder", static.GenericBuilder) + assert.False(t, static.HasStatus) + assert.False(t, static.HasGrace) + assert.False(t, static.HasSuspension) + assert.Empty(t, static.LifecycleInterfaces) + + workload := VariantWorkload.Spec() + assert.Equal(t, "NewWorkloadBuilder", workload.GenericConstructor) + assert.Equal(t, "WithCustomConvergeStatus", workload.StatusSetter) + assert.Equal(t, "concepts.AliveStatusWithReason", workload.StatusResult) + assert.Equal(t, "concepts.AliveConvergingStatusHealthy", workload.StatusConstant) + assert.True(t, workload.HasGrace) + assert.True(t, workload.HasSuspension) + assert.Equal(t, []string{ + "concepts.Alive: for health and readiness tracking.", + "concepts.Graceful: for health reporting once the grace period expires.", + }, workload.LifecycleInterfaces) + + task := VariantTask.Spec() + assert.Equal(t, "concepts.CompletionStatusWithReason", task.StatusResult) + assert.Equal(t, "concepts.CompletionStatusCompleted", task.StatusConstant) + assert.False(t, task.HasGrace) + assert.True(t, task.HasSuspension) + assert.Equal(t, []string{"concepts.Completable: for run-to-completion tracking."}, task.LifecycleInterfaces) + + integration := VariantIntegration.Spec() + assert.Equal(t, "WithCustomOperationalStatus", integration.StatusSetter) + assert.Equal(t, "DefaultOperationalStatusHandler", integration.StatusHandler) + assert.Equal(t, "concepts.OperationalStatusOperational", integration.StatusConstant) + assert.True(t, integration.HasGrace) + assert.Equal(t, []string{ + "concepts.Operational: for external-dependency readiness tracking.", + "concepts.Graceful: for health reporting once the grace period expires.", + }, integration.LifecycleInterfaces) +} diff --git a/internal/scaffold/render.go b/internal/scaffold/render.go new file mode 100644 index 00000000..6f255c4e --- /dev/null +++ b/internal/scaffold/render.go @@ -0,0 +1,66 @@ +package scaffold + +import ( + "bytes" + "embed" + "fmt" + "go/format" + "text/template" +) + +//go:embed templates/*.tmpl +var templateFS embed.FS + +// GeneratedFiles lists the files a wrapper package consists of, in write order. +var GeneratedFiles = []string{"builder.go", "builder_test.go", "mutator.go", "resource.go"} + +// templatePaths maps each generated file to its embedded template. +var templatePaths = map[string]string{ + "builder.go": "templates/builder.go.tmpl", + "builder_test.go": "templates/builder_test.go.tmpl", + "mutator.go": "templates/mutator.go.tmpl", + "resource.go": "templates/resource.go.tmpl", +} + +// Render renders every file of a wrapper package, keyed by file name. Output is +// passed through go/format, so templates do not need to be whitespace-perfect. +func Render(data TemplateData) (map[string][]byte, error) { + if data.Spec().GenericBuilder == "" { + return nil, fmt.Errorf("unknown variant %q", data.Variant) + } + + rendered := make(map[string][]byte, len(GeneratedFiles)) + + for _, name := range GeneratedFiles { + path := templatePaths[name] + + tmpl, err := template.New(name).ParseFS(templateFS, path) + if err != nil { + return nil, fmt.Errorf("parse template %s: %w", path, err) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, templateName(path), data); err != nil { + return nil, fmt.Errorf("render %s: %w", name, err) + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("format %s: %w", name, err) + } + + rendered[name] = formatted + } + + return rendered, nil +} + +// templateName returns the template name ParseFS assigns to an embedded path. +func templateName(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[i+1:] + } + } + return path +} diff --git a/internal/scaffold/render_test.go b/internal/scaffold/render_test.go new file mode 100644 index 00000000..25405051 --- /dev/null +++ b/internal/scaffold/render_test.go @@ -0,0 +1,95 @@ +package scaffold + +import ( + "flag" + "go/parser" + "go/token" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var update = flag.Bool("update", false, "update golden files") + +func goldenCases() map[string]TemplateData { + return map[string]TemplateData{ + "static": { + Package: "configmap", ImportPath: "k8s.io/api/core/v1", ImportAlias: "corev1", + TypeName: "ConfigMap", Group: "", Version: "v1", Kind: "ConfigMap", Variant: VariantStatic, + }, + "workload": { + Package: "deployment", ImportPath: "k8s.io/api/apps/v1", ImportAlias: "appsv1", + TypeName: "Deployment", Group: "apps", Version: "v1", Kind: "Deployment", Variant: VariantWorkload, + }, + "task": { + Package: "job", ImportPath: "k8s.io/api/batch/v1", ImportAlias: "batchv1", + TypeName: "Job", Group: "batch", Version: "v1", Kind: "Job", Variant: VariantTask, + }, + "integration": { + Package: "ingress", ImportPath: "k8s.io/api/networking/v1", ImportAlias: "networkingv1", + TypeName: "Ingress", Group: "networking.k8s.io", Version: "v1", Kind: "Ingress", Variant: VariantIntegration, + }, + "static-cluster-scoped": { + Package: "clusterrole", ImportPath: "k8s.io/api/rbac/v1", ImportAlias: "rbacv1", + TypeName: "ClusterRole", Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole", + ClusterScoped: true, Variant: VariantStatic, + }, + } +} + +func TestRenderGolden(t *testing.T) { + for name, data := range goldenCases() { + t.Run(name, func(t *testing.T) { + files, err := Render(data) + require.NoError(t, err) + require.Len(t, files, len(GeneratedFiles)) + + for _, fileName := range GeneratedFiles { + content, ok := files[fileName] + require.True(t, ok, "missing rendered file %s", fileName) + + goldenPath := filepath.Join("testdata", "golden", name, fileName+".golden") + if *update { + require.NoError(t, os.MkdirAll(filepath.Dir(goldenPath), 0o755)) + require.NoError(t, os.WriteFile(goldenPath, content, 0o644)) + continue + } + + expected, err := os.ReadFile(goldenPath) + require.NoError(t, err) + assert.Equal(t, string(expected), string(content)) + } + }) + } +} + +func TestRenderProducesParsableGo(t *testing.T) { + t.Parallel() + + for name, data := range goldenCases() { + t.Run(name, func(t *testing.T) { + t.Parallel() + files, err := Render(data) + require.NoError(t, err) + + for fileName, content := range files { + _, err := parser.ParseFile(token.NewFileSet(), fileName, content, parser.AllErrors) + assert.NoError(t, err, "rendered %s does not parse", fileName) + } + }) + } +} + +func TestRenderRejectsUnknownVariant(t *testing.T) { + t.Parallel() + + _, err := Render(TemplateData{ + Package: "thing", ImportPath: "example.io/api/v1", ImportAlias: "examplev1", + TypeName: "Thing", Version: "v1", Kind: "Thing", Variant: Variant("bogus"), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown variant "bogus"`) +} diff --git a/internal/scaffold/templates/builder.go.tmpl b/internal/scaffold/templates/builder.go.tmpl new file mode 100644 index 00000000..0988c66a --- /dev/null +++ b/internal/scaffold/templates/builder.go.tmpl @@ -0,0 +1,264 @@ +package {{.Package}} + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + {{.ImportAlias}} {{printf "%q" .ImportPath}} +) +{{- $spec := .Spec}} +{{- if $spec.HasStatus}} + +// {{$spec.StatusHandler}} reports whether the {{.Kind}} has reached its {{$spec.StatusNoun}} state. +// +// This is a scaffolded default: it reports {{$spec.StatusValue}} unconditionally, without +// reading the {{.Kind}}'s status. Replace it with logic that inspects the fields +// your {{.Kind}} reports its {{$spec.StatusNoun}} state through. +func {{$spec.StatusHandler}}( + _ concepts.ConvergingOperation, _ {{.PointerType}}, +) ({{$spec.StatusResult}}, error) { + return {{$spec.StatusResult}}{ + Status: {{$spec.StatusConstant}}, + Reason: "Scaffolded default status, replace with {{.Kind}}-specific logic", + }, nil +} +{{- end}} +{{- if $spec.HasGrace}} + +// DefaultGraceStatusHandler reports the {{.Kind}}'s health once the component's grace +// period has expired. +// +// This is a scaffolded default: it reports Healthy unconditionally. Replace it with +// logic that distinguishes Healthy, Degraded, and Down for your {{.Kind}}, and keep it +// consistent with {{$spec.StatusHandler}}: grace must not report Healthy for a state +// the status handler considers unhealthy. +func DefaultGraceStatusHandler(_ {{.PointerType}}) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusHealthy, + Reason: "Scaffolded default grace status, replace with {{.Kind}}-specific logic", + }, nil +} +{{- end}} +{{- if $spec.HasSuspension}} + +// DefaultSuspendMutationHandler is the mutation applied to the {{.Kind}} when the +// component is suspended. +// +// This is a scaffolded default: it records no mutation, so the {{.Kind}} is left +// untouched while suspended. Replace it with the change that takes the {{.Kind}} +// out of service, for example scaling to zero or setting a suspended field. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler reports progress towards a suspended state. +// +// This is a scaffolded default: it reports Suspended immediately, matching the +// no-op suspension mutation. Replace it alongside DefaultSuspendMutationHandler so +// the reported progress reflects the mutation you apply. +func DefaultSuspensionStatusHandler(_ {{.PointerType}}) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Scaffolded default suspension status, replace with {{.Kind}}-specific logic", + }, nil +} + +// DefaultDeleteOnSuspendHandler decides whether the {{.Kind}} is deleted from the +// cluster when the component is suspended. +// +// This is a scaffolded default: it returns false, so the {{.Kind}} is kept. Return +// true if suspension should remove it instead. +func DefaultDeleteOnSuspendHandler(_ {{.PointerType}}) bool { + return false +} +{{- end}} + +// Builder is a configuration helper for creating and customizing the {{.Kind}} Resource. +// +{{- if $spec.HasStatus}} +// It provides a fluent API for registering mutations, status handlers and declared +// data extractions. Build() validates the configuration and returns an initialized +// Resource ready for use in a reconciliation loop. +{{- else}} +// It provides a fluent API for registering mutations and declared data extractions. +// Build() validates the configuration and returns an initialized Resource ready for +// use in a reconciliation loop. +{{- end}} +type Builder struct { + base *generic.{{$spec.GenericBuilder}}[{{.PointerType}}, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided {{.Kind}} object. +// +// The {{.Kind}} object serves as the desired base state. During reconciliation the +// framework makes the cluster's state match this base state, modified by any +// registered mutations. +// +{{- if .ClusterScoped}} +// The provided {{.Kind}} must have a Name set, and must not have a Namespace because +// the kind is cluster-scoped, which is validated during the Build() call. +{{- else}} +// The provided {{.Kind}} must have a Name set and a Namespace set, which is +// validated during the Build() call. +{{- end}} +func NewBuilder(obj {{.PointerType}}) *Builder { + identityFunc := func(o {{.PointerType}}) string { + return fmt.Sprintf({{printf "%q" .IdentityFormat}}, {{.IdentityArgs}}) + } + + base := generic.{{$spec.GenericConstructor}}[{{.PointerType}}, *Mutator]( + obj, + identityFunc, + NewMutator, + ) +{{- if .ClusterScoped}} + + base.MarkClusterScoped() +{{- end}} +{{- if $spec.HasStatus}} + + base. + {{$spec.StatusSetter}}({{$spec.StatusHandler}}). +{{- if $spec.HasGrace}} + WithCustomGraceStatus(DefaultGraceStatusHandler). +{{- end}} + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) +{{- end}} + + return &Builder{ + base: base, + } +} + +// WithMutation registers one or more feature-based mutations for the {{.Kind}}. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} +{{- if $spec.HasStatus}} + +// {{$spec.StatusSetter}} overrides the default logic for determining whether the +// {{.Kind}} has reached its {{$spec.StatusNoun}} state. +// +// The default behavior uses {{$spec.StatusHandler}}, which reports {{$spec.StatusValue}} +// unconditionally. This handler is required by the generic layer, so it is registered +// in NewBuilder and can only be replaced, never cleared. +func (b *Builder) {{$spec.StatusSetter}}( + handler func(concepts.ConvergingOperation, {{.PointerType}}) ({{$spec.StatusResult}}, error), +) *Builder { + b.base.{{$spec.StatusSetter}}(handler) + return b +} +{{- end}} +{{- if $spec.HasGrace}} + +// WithCustomGraceStatus overrides how the {{.Kind}} reports its health once the +// component's grace period has expired. +// +// The default behavior uses DefaultGraceStatusHandler. +func (b *Builder) WithCustomGraceStatus( + handler func({{.PointerType}}) (concepts.GraceStatusWithReason, error), +) *Builder { + b.base.WithCustomGraceStatus(handler) + return b +} +{{- end}} +{{- if $spec.HasSuspension}} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler. +func (b *Builder) WithCustomSuspendStatus( + handler func({{.PointerType}}) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the {{.Kind}} is modified when the component +// is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler. +func (b *Builder) WithCustomSuspendMutation(handler func(*Mutator) error) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete the +// {{.Kind}} when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler. +func (b *Builder) WithCustomSuspendDeletionDecision(handler func({{.PointerType}}) bool) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} +{{- end}} + +// WithGuard registers a guard precondition that is evaluated before the {{.Kind}} is +// applied during reconciliation. If the guard returns Blocked, the {{.Kind}} and all +// resources registered after it are skipped until the guard clears. +// Passing nil clears any previously registered guard. +func (b *Builder) WithGuard( + guard func({{.QualifiedType}}) (concepts.GuardStatusWithReason, error), +) *Builder { + b.base.WithGuard(generic.WrapGuard(guard)) + return b +} + +// WithDataGuard declares that the {{.Kind}} reads the given data cells and must not +// be applied until every one of them is set. The framework generates the guard and +// its reason (waiting for data ""), and component Build validates that a +// producer for each cell is registered earlier. Data guards are evaluated before any +// custom guard registered with WithGuard. +func (b *Builder) WithDataGuard(cells ...concepts.DataCell) *Builder { + b.base.WithDataGuard(cells...) + return b +} + +// WithOptionalData declares that the {{.Kind}} reads the given data cells without +// gating on them. Component Build still validates that a producer is registered +// earlier, and the dependency stays visible to introspection. Consumers in this mode +// use Get and skip quietly when a cell is absent. +func (b *Builder) WithOptionalData(cells ...concepts.DataCell) *Builder { + b.base.WithOptionalData(cells...) + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No {{.Kind}} object was provided. +// - The {{.Kind}} is missing a Name. +{{- if .ClusterScoped}} +// - The {{.Kind}} has a Namespace set, which a cluster-scoped kind must not have. +{{- else}} +// - The {{.Kind}} is missing a Namespace. +{{- end}} +// - Two registered mutations share a name. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} + +// ExtractInto declares that this {{.Kind}} produces the value of cell. fn computes +// the value from a copy of the reconciled {{.Kind}}; the framework stores it in the +// cell and marks it present, immediately after the {{.Kind}} is applied or fetched. +// Extracting several values means several ExtractInto calls, one per cell. This is a +// package-level function because Go methods cannot introduce the extra type +// parameter V. +func ExtractInto[V any](b *Builder, cell *concepts.Data[V], fn func({{.QualifiedType}}) (V, error)) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} diff --git a/internal/scaffold/templates/builder_test.go.tmpl b/internal/scaffold/templates/builder_test.go.tmpl new file mode 100644 index 00000000..ed6e1bdb --- /dev/null +++ b/internal/scaffold/templates/builder_test.go.tmpl @@ -0,0 +1,175 @@ +package {{.Package}} + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + {{.ImportAlias}} {{printf "%q" .ImportPath}} + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +{{- if .ClusterScoped}} + +// testObject returns a valid cluster-scoped {{.Kind}} fixture. +func testObject() {{.PointerType}} { + return &{{.QualifiedType}}{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object"}, + } +} +{{- else}} + +// testObject returns a valid namespaced {{.Kind}} fixture. +func testObject() {{.PointerType}} { + return &{{.QualifiedType}}{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object", Namespace: "test-ns"}, + } +} +{{- end}} + +func TestBuilderBuildValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj {{.PointerType}} + expectedErr string + }{ + { + name: "nil object", + obj: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + obj: &{{.QualifiedType}}{ +{{- if .ClusterScoped}} + ObjectMeta: metav1.ObjectMeta{}, +{{- else}} + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, +{{- end}} + }, + expectedErr: "object name cannot be empty", + }, +{{- if .ClusterScoped}} + { + name: "namespace set on cluster-scoped object", + obj: &{{.QualifiedType}}{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object", Namespace: "test-ns"}, + }, + expectedErr: "cluster-scoped object must not have a namespace", + }, +{{- else}} + { + name: "empty namespace", + obj: &{{.QualifiedType}}{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object"}, + }, + expectedErr: "object namespace cannot be empty", + }, +{{- end}} + { + name: "valid object", + obj: testObject(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(tt.obj).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + return + } + + require.NoError(t, err) + require.NotNil(t, res) +{{- if .ClusterScoped}} + assert.Equal(t, {{printf "%q" (printf "%s/%s/test-object" .APIVersion .Kind)}}, res.Identity()) +{{- else}} + assert.Equal(t, {{printf "%q" (printf "%s/%s/test-ns/test-object" .APIVersion .Kind)}}, res.Identity()) +{{- end}} + }) + } +} + +func TestMutationAppliesThroughMutator(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(testObject()). + WithMutation(Mutation{ + Name: "scaffolded-label", + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("scaffolded-by", "ocf") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + assert.Equal(t, []string{"scaffolded-label"}, res.RegisteredMutations()) + + current := testObject() + require.NoError(t, res.Mutate(current)) + assert.Equal(t, "ocf", current.Labels["scaffolded-by"]) +} + +func TestExtractIntoDeclaredExtraction(t *testing.T) { + t.Parallel() + + cell := concepts.NewData[string]("{{.Package}}-name") + builder := NewBuilder(testObject()) + ExtractInto(builder, cell, func(o {{.QualifiedType}}) (string, error) { + return o.Name, nil + }) + + res, err := builder.Build() + require.NoError(t, err) + + produced := res.ProducedData() + require.Len(t, produced, 1) + assert.Equal(t, "{{.Package}}-name", produced[0].Name()) + + require.NoError(t, res.ExtractData()) + value, ok := cell.Get() + assert.True(t, ok) + assert.Equal(t, "test-object", value) +} + +func TestWithDataGuardAndOptionalDataDeclarations(t *testing.T) { + t.Parallel() + + guarded := concepts.NewData[string]("db-host") + optional := concepts.NewData[string]("db-port") + + res, err := NewBuilder(testObject()). + WithDataGuard(guarded). + WithOptionalData(optional). + Build() + require.NoError(t, err) + + consumed := res.ConsumedData() + require.Len(t, consumed, 2) + assert.Equal(t, "db-host", consumed[0].Cell.Name()) + assert.False(t, consumed[0].Optional) + assert.Equal(t, "db-port", consumed[1].Cell.Name()) + assert.True(t, consumed[1].Optional) + + status, err := res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusBlocked, status.Status) + assert.Equal(t, `waiting for data "db-host"`, status.Reason) + + guarded.Set("postgres.default.svc") + status, err = res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusUnblocked, status.Status) +} diff --git a/internal/scaffold/templates/mutator.go.tmpl b/internal/scaffold/templates/mutator.go.tmpl new file mode 100644 index 00000000..53590dac --- /dev/null +++ b/internal/scaffold/templates/mutator.go.tmpl @@ -0,0 +1,116 @@ +// Package {{.Package}} provides a builder and resource for managing {{.Kind}} objects. +package {{.Package}} + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + {{.ImportAlias}} {{printf "%q" .ImportPath}} +) + +// Mutation defines a mutation that is applied to the {{.Kind}} Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + objectEdits []func({{.PointerType}}) error +} + +// Mutator is a high-level helper for modifying {{.Kind}} objects. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the {{.Kind}} in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Object edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + obj {{.PointerType}} + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given {{.Kind}}. +// The constructor creates the initial feature scope automatically. +func NewMutator(obj {{.PointerType}}) *Mutator { + m := &Mutator{ + obj: obj, + } + m.NextFeature() + return m +} + +// NextFeature advances to a new feature planning scope. All subsequent mutation +// registrations will be grouped into this scope until NextFeature is called again. +// +// The first scope is created automatically by NewMutator. This method is called +// by the framework between mutations to maintain per-feature ordering semantics. +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the {{.Kind}}'s own metadata. +// +// Metadata edits are applied before object edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// Edit records a mutation for the {{.Kind}} itself. +// +// The edit function receives the object being reconciled and may set any field +// on it. Wrap frequently used edits in named methods on the Mutator so feature +// mutations stay self-documenting, the way the built-in primitives layer typed +// helpers over their editors. Object edits are applied after metadata edits +// within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) Edit(edit func({{.PointerType}}) error) { + if edit == nil { + return + } + m.active.objectEdits = append(m.active.objectEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying {{.Kind}}. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Object edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the {{.Kind}} as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.obj.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Object edits + for _, edit := range plan.objectEdits { + if err := edit(m.obj); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/scaffold/templates/resource.go.tmpl b/internal/scaffold/templates/resource.go.tmpl new file mode 100644 index 00000000..b8e9177e --- /dev/null +++ b/internal/scaffold/templates/resource.go.tmpl @@ -0,0 +1,171 @@ +package {{.Package}} + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + {{.ImportAlias}} {{printf "%q" .ImportPath}} + "sigs.k8s.io/controller-runtime/pkg/client" +) +{{- $spec := .Spec}} + +// Resource is a high-level abstraction for managing {{.Kind}} objects within a +// controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +{{- range $spec.LifecycleInterfaces}} +// - {{.}} +{{- end}} +{{- if $spec.HasSuspension}} +// - concepts.Suspendable: for temporary deactivation. +{{- end}} +// - concepts.Guardable: for conditional reconciliation based on a guard precondition. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// - concepts.DataProducer and concepts.DataConsumer: for declared data topology. +// - concepts.ObservationRecorder: for surfacing live cluster state to declared data extractions on read-only reconciliation. +// - concepts.Previewable: for cluster-free rendering of the desired state. +// - concepts.MutationInspector: for introspecting registered and firing mutations. +type Resource struct { + base *generic.{{$spec.GenericResource}}[{{.PointerType}}, *Mutator] +} + +// Identity returns a unique identifier for the {{.Kind}} in the format +{{- if .ClusterScoped}} +// "{{.APIVersion}}/{{.Kind}}/". +{{- else}} +// "{{.APIVersion}}/{{.Kind}}//". +{{- end}} +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying {{.Kind}} object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of the {{.Kind}} into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +{{- if $spec.HasSuspension}} +// 3. Suspension: if the resource is suspending, the suspension mutation is applied. +{{- end}} +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} +{{- if $spec.HasStatus}} + +// {{$spec.StatusMethod}} evaluates whether the {{.Kind}} has reached its {{$spec.StatusNoun}} state. +// +// By default it uses {{$spec.StatusHandler}}. The return value carries a status and +// a human-readable reason, both surfaced in the component's conditions. +func (r *Resource) {{$spec.StatusMethod}}(op concepts.ConvergingOperation) ({{$spec.StatusResult}}, error) { + return r.base.{{$spec.StatusMethod}}(op) +} +{{- end}} +{{- if $spec.HasGrace}} + +// GraceStatus provides a health assessment of the {{.Kind}} once the component's +// grace period has expired. +// +// By default it uses DefaultGraceStatusHandler. +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} +{{- end}} +{{- if $spec.HasSuspension}} + +// DeleteOnSuspend determines whether the {{.Kind}} is deleted from the cluster when +// the parent component is suspended. +// +// By default it uses DefaultDeleteOnSuspendHandler. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the {{.Kind}}. +// +// It registers a mutation executed during the next Mutate call. By default it uses +// DefaultSuspendMutationHandler. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default it uses DefaultSuspensionStatusHandler. The framework uses it to decide +// when the component has reached a fully suspended state. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} +{{- end}} + +// GuardStatus evaluates the resource's guard precondition. +// If no guard was registered, the resource is unconditionally unblocked. +func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) { + return r.base.GuardStatus() +} + +// ExtractData executes all declared data extractions against a deep copy of the +// reconciled {{.Kind}}. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the {{.Kind}}. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} + +// ProducedData returns the cells this {{.Kind}} declares extractions into. +// It satisfies concepts.DataProducer for component topology validation and +// introspection. +func (r *Resource) ProducedData() []concepts.DataCell { + return r.base.ProducedData() +} + +// ConsumedData returns the {{.Kind}}'s declared data reads. It satisfies +// concepts.DataConsumer for component topology validation and introspection. +func (r *Resource) ConsumedData() []concepts.DataConsumption { + return r.base.ConsumedData() +} + +// RecordObservation stores the supplied object as the resource's most recently +// observed cluster state. The framework invokes this on read-only resources after +// fetching them so that declared data extractions observe the live object rather +// than the inert base used to construct the resource. +func (r *Resource) RecordObservation(observed client.Object) error { + return r.base.RecordObservation(observed) +} + +// Preview renders the {{.Kind}} as a client.Object with feature mutations applied, +// without modifying the resource's internal state. It satisfies the component's +// Previewable capability so the component can assemble a cluster-free preview. +// +// Suspension mutations are not applied; the preview reflects content state only. +// Callers needing the concrete type can type-assert the returned object. +func (r *Resource) Preview() (client.Object, error) { + return r.base.Preview() +} + +// RegisteredMutations returns the deduplicated Names of every mutation registered on +// the {{.Kind}}, independent of version. It satisfies concepts.MutationInspector so +// the resource can be introspected for version-matrix golden generation. +func (r *Resource) RegisteredMutations() []string { + return r.base.RegisteredMutations() +} + +// FiringSet returns the Names of registered mutations whose gate is enabled for the +// version the {{.Kind}} was built at. It satisfies concepts.MutationInspector. +func (r *Resource) FiringSet() ([]string, error) { + return r.base.FiringSet() +} + +var _ concepts.MutationInspector = (*Resource)(nil) +var _ concepts.DataProducer = (*Resource)(nil) +var _ concepts.DataConsumer = (*Resource)(nil) diff --git a/internal/scaffold/testdata/golden/integration/builder.go.golden b/internal/scaffold/testdata/golden/integration/builder.go.golden new file mode 100644 index 00000000..a68bb57a --- /dev/null +++ b/internal/scaffold/testdata/golden/integration/builder.go.golden @@ -0,0 +1,228 @@ +package ingress + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + networkingv1 "k8s.io/api/networking/v1" +) + +// DefaultOperationalStatusHandler reports whether the Ingress has reached its operational state. +// +// This is a scaffolded default: it reports Operational unconditionally, without +// reading the Ingress's status. Replace it with logic that inspects the fields +// your Ingress reports its operational state through. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, _ *networkingv1.Ingress, +) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "Scaffolded default status, replace with Ingress-specific logic", + }, nil +} + +// DefaultGraceStatusHandler reports the Ingress's health once the component's grace +// period has expired. +// +// This is a scaffolded default: it reports Healthy unconditionally. Replace it with +// logic that distinguishes Healthy, Degraded, and Down for your Ingress, and keep it +// consistent with DefaultOperationalStatusHandler: grace must not report Healthy for a state +// the status handler considers unhealthy. +func DefaultGraceStatusHandler(_ *networkingv1.Ingress) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusHealthy, + Reason: "Scaffolded default grace status, replace with Ingress-specific logic", + }, nil +} + +// DefaultSuspendMutationHandler is the mutation applied to the Ingress when the +// component is suspended. +// +// This is a scaffolded default: it records no mutation, so the Ingress is left +// untouched while suspended. Replace it with the change that takes the Ingress +// out of service, for example scaling to zero or setting a suspended field. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler reports progress towards a suspended state. +// +// This is a scaffolded default: it reports Suspended immediately, matching the +// no-op suspension mutation. Replace it alongside DefaultSuspendMutationHandler so +// the reported progress reflects the mutation you apply. +func DefaultSuspensionStatusHandler(_ *networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Scaffolded default suspension status, replace with Ingress-specific logic", + }, nil +} + +// DefaultDeleteOnSuspendHandler decides whether the Ingress is deleted from the +// cluster when the component is suspended. +// +// This is a scaffolded default: it returns false, so the Ingress is kept. Return +// true if suspension should remove it instead. +func DefaultDeleteOnSuspendHandler(_ *networkingv1.Ingress) bool { + return false +} + +// Builder is a configuration helper for creating and customizing the Ingress Resource. +// +// It provides a fluent API for registering mutations, status handlers and declared +// data extractions. Build() validates the configuration and returns an initialized +// Resource ready for use in a reconciliation loop. +type Builder struct { + base *generic.IntegrationBuilder[*networkingv1.Ingress, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Ingress object. +// +// The Ingress object serves as the desired base state. During reconciliation the +// framework makes the cluster's state match this base state, modified by any +// registered mutations. +// +// The provided Ingress must have a Name set and a Namespace set, which is +// validated during the Build() call. +func NewBuilder(obj *networkingv1.Ingress) *Builder { + identityFunc := func(o *networkingv1.Ingress) string { + return fmt.Sprintf("networking.k8s.io/v1/Ingress/%s/%s", o.Namespace, o.Name) + } + + base := generic.NewIntegrationBuilder[*networkingv1.Ingress, *Mutator]( + obj, + identityFunc, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomGraceStatus(DefaultGraceStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers one or more feature-based mutations for the Ingress. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} + +// WithCustomOperationalStatus overrides the default logic for determining whether the +// Ingress has reached its operational state. +// +// The default behavior uses DefaultOperationalStatusHandler, which reports Operational +// unconditionally. This handler is required by the generic layer, so it is registered +// in NewBuilder and can only be replaced, never cleared. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *networkingv1.Ingress) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the Ingress reports its health once the +// component's grace period has expired. +// +// The default behavior uses DefaultGraceStatusHandler. +func (b *Builder) WithCustomGraceStatus( + handler func(*networkingv1.Ingress) (concepts.GraceStatusWithReason, error), +) *Builder { + b.base.WithCustomGraceStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Ingress is modified when the component +// is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler. +func (b *Builder) WithCustomSuspendMutation(handler func(*Mutator) error) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete the +// Ingress when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler. +func (b *Builder) WithCustomSuspendDeletionDecision(handler func(*networkingv1.Ingress) bool) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithGuard registers a guard precondition that is evaluated before the Ingress is +// applied during reconciliation. If the guard returns Blocked, the Ingress and all +// resources registered after it are skipped until the guard clears. +// Passing nil clears any previously registered guard. +func (b *Builder) WithGuard( + guard func(networkingv1.Ingress) (concepts.GuardStatusWithReason, error), +) *Builder { + b.base.WithGuard(generic.WrapGuard(guard)) + return b +} + +// WithDataGuard declares that the Ingress reads the given data cells and must not +// be applied until every one of them is set. The framework generates the guard and +// its reason (waiting for data ""), and component Build validates that a +// producer for each cell is registered earlier. Data guards are evaluated before any +// custom guard registered with WithGuard. +func (b *Builder) WithDataGuard(cells ...concepts.DataCell) *Builder { + b.base.WithDataGuard(cells...) + return b +} + +// WithOptionalData declares that the Ingress reads the given data cells without +// gating on them. Component Build still validates that a producer is registered +// earlier, and the dependency stays visible to introspection. Consumers in this mode +// use Get and skip quietly when a cell is absent. +func (b *Builder) WithOptionalData(cells ...concepts.DataCell) *Builder { + b.base.WithOptionalData(cells...) + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Ingress object was provided. +// - The Ingress is missing a Name. +// - The Ingress is missing a Namespace. +// - Two registered mutations share a name. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} + +// ExtractInto declares that this Ingress produces the value of cell. fn computes +// the value from a copy of the reconciled Ingress; the framework stores it in the +// cell and marks it present, immediately after the Ingress is applied or fetched. +// Extracting several values means several ExtractInto calls, one per cell. This is a +// package-level function because Go methods cannot introduce the extra type +// parameter V. +func ExtractInto[V any](b *Builder, cell *concepts.Data[V], fn func(networkingv1.Ingress) (V, error)) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} diff --git a/internal/scaffold/testdata/golden/integration/builder_test.go.golden b/internal/scaffold/testdata/golden/integration/builder_test.go.golden new file mode 100644 index 00000000..97322511 --- /dev/null +++ b/internal/scaffold/testdata/golden/integration/builder_test.go.golden @@ -0,0 +1,146 @@ +package ingress + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// testObject returns a valid namespaced Ingress fixture. +func testObject() *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object", Namespace: "test-ns"}, + } +} + +func TestBuilderBuildValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj *networkingv1.Ingress + expectedErr string + }{ + { + name: "nil object", + obj: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + obj: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + obj: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid object", + obj: testObject(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(tt.obj).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + return + } + + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "networking.k8s.io/v1/Ingress/test-ns/test-object", res.Identity()) + }) + } +} + +func TestMutationAppliesThroughMutator(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(testObject()). + WithMutation(Mutation{ + Name: "scaffolded-label", + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("scaffolded-by", "ocf") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + assert.Equal(t, []string{"scaffolded-label"}, res.RegisteredMutations()) + + current := testObject() + require.NoError(t, res.Mutate(current)) + assert.Equal(t, "ocf", current.Labels["scaffolded-by"]) +} + +func TestExtractIntoDeclaredExtraction(t *testing.T) { + t.Parallel() + + cell := concepts.NewData[string]("ingress-name") + builder := NewBuilder(testObject()) + ExtractInto(builder, cell, func(o networkingv1.Ingress) (string, error) { + return o.Name, nil + }) + + res, err := builder.Build() + require.NoError(t, err) + + produced := res.ProducedData() + require.Len(t, produced, 1) + assert.Equal(t, "ingress-name", produced[0].Name()) + + require.NoError(t, res.ExtractData()) + value, ok := cell.Get() + assert.True(t, ok) + assert.Equal(t, "test-object", value) +} + +func TestWithDataGuardAndOptionalDataDeclarations(t *testing.T) { + t.Parallel() + + guarded := concepts.NewData[string]("db-host") + optional := concepts.NewData[string]("db-port") + + res, err := NewBuilder(testObject()). + WithDataGuard(guarded). + WithOptionalData(optional). + Build() + require.NoError(t, err) + + consumed := res.ConsumedData() + require.Len(t, consumed, 2) + assert.Equal(t, "db-host", consumed[0].Cell.Name()) + assert.False(t, consumed[0].Optional) + assert.Equal(t, "db-port", consumed[1].Cell.Name()) + assert.True(t, consumed[1].Optional) + + status, err := res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusBlocked, status.Status) + assert.Equal(t, `waiting for data "db-host"`, status.Reason) + + guarded.Set("postgres.default.svc") + status, err = res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusUnblocked, status.Status) +} diff --git a/internal/scaffold/testdata/golden/integration/mutator.go.golden b/internal/scaffold/testdata/golden/integration/mutator.go.golden new file mode 100644 index 00000000..c780afbf --- /dev/null +++ b/internal/scaffold/testdata/golden/integration/mutator.go.golden @@ -0,0 +1,116 @@ +// Package ingress provides a builder and resource for managing Ingress objects. +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + networkingv1 "k8s.io/api/networking/v1" +) + +// Mutation defines a mutation that is applied to the Ingress Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + objectEdits []func(*networkingv1.Ingress) error +} + +// Mutator is a high-level helper for modifying Ingress objects. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Ingress in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Object edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + obj *networkingv1.Ingress + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Ingress. +// The constructor creates the initial feature scope automatically. +func NewMutator(obj *networkingv1.Ingress) *Mutator { + m := &Mutator{ + obj: obj, + } + m.NextFeature() + return m +} + +// NextFeature advances to a new feature planning scope. All subsequent mutation +// registrations will be grouped into this scope until NextFeature is called again. +// +// The first scope is created automatically by NewMutator. This method is called +// by the framework between mutations to maintain per-feature ordering semantics. +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Ingress's own metadata. +// +// Metadata edits are applied before object edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// Edit records a mutation for the Ingress itself. +// +// The edit function receives the object being reconciled and may set any field +// on it. Wrap frequently used edits in named methods on the Mutator so feature +// mutations stay self-documenting, the way the built-in primitives layer typed +// helpers over their editors. Object edits are applied after metadata edits +// within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) Edit(edit func(*networkingv1.Ingress) error) { + if edit == nil { + return + } + m.active.objectEdits = append(m.active.objectEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying Ingress. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Object edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Ingress as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.obj.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Object edits + for _, edit := range plan.objectEdits { + if err := edit(m.obj); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/scaffold/testdata/golden/integration/resource.go.golden b/internal/scaffold/testdata/golden/integration/resource.go.golden new file mode 100644 index 00000000..315a713f --- /dev/null +++ b/internal/scaffold/testdata/golden/integration/resource.go.golden @@ -0,0 +1,155 @@ +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing Ingress objects within a +// controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.Operational: for external-dependency readiness tracking. +// - concepts.Graceful: for health reporting once the grace period expires. +// - concepts.Suspendable: for temporary deactivation. +// - concepts.Guardable: for conditional reconciliation based on a guard precondition. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// - concepts.DataProducer and concepts.DataConsumer: for declared data topology. +// - concepts.ObservationRecorder: for surfacing live cluster state to declared data extractions on read-only reconciliation. +// - concepts.Previewable: for cluster-free rendering of the desired state. +// - concepts.MutationInspector: for introspecting registered and firing mutations. +type Resource struct { + base *generic.IntegrationResource[*networkingv1.Ingress, *Mutator] +} + +// Identity returns a unique identifier for the Ingress in the format +// "networking.k8s.io/v1/Ingress//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Ingress object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of the Ingress into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// 3. Suspension: if the resource is suspending, the suspension mutation is applied. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates whether the Ingress has reached its operational state. +// +// By default it uses DefaultOperationalStatusHandler. The return value carries a status and +// a human-readable reason, both surfaced in the component's conditions. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// GraceStatus provides a health assessment of the Ingress once the component's +// grace period has expired. +// +// By default it uses DefaultGraceStatusHandler. +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} + +// DeleteOnSuspend determines whether the Ingress is deleted from the cluster when +// the parent component is suspended. +// +// By default it uses DefaultDeleteOnSuspendHandler. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the Ingress. +// +// It registers a mutation executed during the next Mutate call. By default it uses +// DefaultSuspendMutationHandler. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default it uses DefaultSuspensionStatusHandler. The framework uses it to decide +// when the component has reached a fully suspended state. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// GuardStatus evaluates the resource's guard precondition. +// If no guard was registered, the resource is unconditionally unblocked. +func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) { + return r.base.GuardStatus() +} + +// ExtractData executes all declared data extractions against a deep copy of the +// reconciled Ingress. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the Ingress. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} + +// ProducedData returns the cells this Ingress declares extractions into. +// It satisfies concepts.DataProducer for component topology validation and +// introspection. +func (r *Resource) ProducedData() []concepts.DataCell { + return r.base.ProducedData() +} + +// ConsumedData returns the Ingress's declared data reads. It satisfies +// concepts.DataConsumer for component topology validation and introspection. +func (r *Resource) ConsumedData() []concepts.DataConsumption { + return r.base.ConsumedData() +} + +// RecordObservation stores the supplied object as the resource's most recently +// observed cluster state. The framework invokes this on read-only resources after +// fetching them so that declared data extractions observe the live object rather +// than the inert base used to construct the resource. +func (r *Resource) RecordObservation(observed client.Object) error { + return r.base.RecordObservation(observed) +} + +// Preview renders the Ingress as a client.Object with feature mutations applied, +// without modifying the resource's internal state. It satisfies the component's +// Previewable capability so the component can assemble a cluster-free preview. +// +// Suspension mutations are not applied; the preview reflects content state only. +// Callers needing the concrete type can type-assert the returned object. +func (r *Resource) Preview() (client.Object, error) { + return r.base.Preview() +} + +// RegisteredMutations returns the deduplicated Names of every mutation registered on +// the Ingress, independent of version. It satisfies concepts.MutationInspector so +// the resource can be introspected for version-matrix golden generation. +func (r *Resource) RegisteredMutations() []string { + return r.base.RegisteredMutations() +} + +// FiringSet returns the Names of registered mutations whose gate is enabled for the +// version the Ingress was built at. It satisfies concepts.MutationInspector. +func (r *Resource) FiringSet() ([]string, error) { + return r.base.FiringSet() +} + +var _ concepts.MutationInspector = (*Resource)(nil) +var _ concepts.DataProducer = (*Resource)(nil) +var _ concepts.DataConsumer = (*Resource)(nil) diff --git a/internal/scaffold/testdata/golden/static-cluster-scoped/builder.go.golden b/internal/scaffold/testdata/golden/static-cluster-scoped/builder.go.golden new file mode 100644 index 00000000..53764c82 --- /dev/null +++ b/internal/scaffold/testdata/golden/static-cluster-scoped/builder.go.golden @@ -0,0 +1,112 @@ +package clusterrole + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + rbacv1 "k8s.io/api/rbac/v1" +) + +// Builder is a configuration helper for creating and customizing the ClusterRole Resource. +// +// It provides a fluent API for registering mutations and declared data extractions. +// Build() validates the configuration and returns an initialized Resource ready for +// use in a reconciliation loop. +type Builder struct { + base *generic.StaticBuilder[*rbacv1.ClusterRole, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided ClusterRole object. +// +// The ClusterRole object serves as the desired base state. During reconciliation the +// framework makes the cluster's state match this base state, modified by any +// registered mutations. +// +// The provided ClusterRole must have a Name set, and must not have a Namespace because +// the kind is cluster-scoped, which is validated during the Build() call. +func NewBuilder(obj *rbacv1.ClusterRole) *Builder { + identityFunc := func(o *rbacv1.ClusterRole) string { + return fmt.Sprintf("rbac.authorization.k8s.io/v1/ClusterRole/%s", o.Name) + } + + base := generic.NewStaticBuilder[*rbacv1.ClusterRole, *Mutator]( + obj, + identityFunc, + NewMutator, + ) + + base.MarkClusterScoped() + + return &Builder{ + base: base, + } +} + +// WithMutation registers one or more feature-based mutations for the ClusterRole. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} + +// WithGuard registers a guard precondition that is evaluated before the ClusterRole is +// applied during reconciliation. If the guard returns Blocked, the ClusterRole and all +// resources registered after it are skipped until the guard clears. +// Passing nil clears any previously registered guard. +func (b *Builder) WithGuard( + guard func(rbacv1.ClusterRole) (concepts.GuardStatusWithReason, error), +) *Builder { + b.base.WithGuard(generic.WrapGuard(guard)) + return b +} + +// WithDataGuard declares that the ClusterRole reads the given data cells and must not +// be applied until every one of them is set. The framework generates the guard and +// its reason (waiting for data ""), and component Build validates that a +// producer for each cell is registered earlier. Data guards are evaluated before any +// custom guard registered with WithGuard. +func (b *Builder) WithDataGuard(cells ...concepts.DataCell) *Builder { + b.base.WithDataGuard(cells...) + return b +} + +// WithOptionalData declares that the ClusterRole reads the given data cells without +// gating on them. Component Build still validates that a producer is registered +// earlier, and the dependency stays visible to introspection. Consumers in this mode +// use Get and skip quietly when a cell is absent. +func (b *Builder) WithOptionalData(cells ...concepts.DataCell) *Builder { + b.base.WithOptionalData(cells...) + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No ClusterRole object was provided. +// - The ClusterRole is missing a Name. +// - The ClusterRole has a Namespace set, which a cluster-scoped kind must not have. +// - Two registered mutations share a name. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} + +// ExtractInto declares that this ClusterRole produces the value of cell. fn computes +// the value from a copy of the reconciled ClusterRole; the framework stores it in the +// cell and marks it present, immediately after the ClusterRole is applied or fetched. +// Extracting several values means several ExtractInto calls, one per cell. This is a +// package-level function because Go methods cannot introduce the extra type +// parameter V. +func ExtractInto[V any](b *Builder, cell *concepts.Data[V], fn func(rbacv1.ClusterRole) (V, error)) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} diff --git a/internal/scaffold/testdata/golden/static-cluster-scoped/builder_test.go.golden b/internal/scaffold/testdata/golden/static-cluster-scoped/builder_test.go.golden new file mode 100644 index 00000000..1cd262b1 --- /dev/null +++ b/internal/scaffold/testdata/golden/static-cluster-scoped/builder_test.go.golden @@ -0,0 +1,146 @@ +package clusterrole + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// testObject returns a valid cluster-scoped ClusterRole fixture. +func testObject() *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object"}, + } +} + +func TestBuilderBuildValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj *rbacv1.ClusterRole + expectedErr string + }{ + { + name: "nil object", + obj: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + obj: &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "namespace set on cluster-scoped object", + obj: &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object", Namespace: "test-ns"}, + }, + expectedErr: "cluster-scoped object must not have a namespace", + }, + { + name: "valid object", + obj: testObject(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(tt.obj).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + return + } + + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "rbac.authorization.k8s.io/v1/ClusterRole/test-object", res.Identity()) + }) + } +} + +func TestMutationAppliesThroughMutator(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(testObject()). + WithMutation(Mutation{ + Name: "scaffolded-label", + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("scaffolded-by", "ocf") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + assert.Equal(t, []string{"scaffolded-label"}, res.RegisteredMutations()) + + current := testObject() + require.NoError(t, res.Mutate(current)) + assert.Equal(t, "ocf", current.Labels["scaffolded-by"]) +} + +func TestExtractIntoDeclaredExtraction(t *testing.T) { + t.Parallel() + + cell := concepts.NewData[string]("clusterrole-name") + builder := NewBuilder(testObject()) + ExtractInto(builder, cell, func(o rbacv1.ClusterRole) (string, error) { + return o.Name, nil + }) + + res, err := builder.Build() + require.NoError(t, err) + + produced := res.ProducedData() + require.Len(t, produced, 1) + assert.Equal(t, "clusterrole-name", produced[0].Name()) + + require.NoError(t, res.ExtractData()) + value, ok := cell.Get() + assert.True(t, ok) + assert.Equal(t, "test-object", value) +} + +func TestWithDataGuardAndOptionalDataDeclarations(t *testing.T) { + t.Parallel() + + guarded := concepts.NewData[string]("db-host") + optional := concepts.NewData[string]("db-port") + + res, err := NewBuilder(testObject()). + WithDataGuard(guarded). + WithOptionalData(optional). + Build() + require.NoError(t, err) + + consumed := res.ConsumedData() + require.Len(t, consumed, 2) + assert.Equal(t, "db-host", consumed[0].Cell.Name()) + assert.False(t, consumed[0].Optional) + assert.Equal(t, "db-port", consumed[1].Cell.Name()) + assert.True(t, consumed[1].Optional) + + status, err := res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusBlocked, status.Status) + assert.Equal(t, `waiting for data "db-host"`, status.Reason) + + guarded.Set("postgres.default.svc") + status, err = res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusUnblocked, status.Status) +} diff --git a/internal/scaffold/testdata/golden/static-cluster-scoped/mutator.go.golden b/internal/scaffold/testdata/golden/static-cluster-scoped/mutator.go.golden new file mode 100644 index 00000000..02fc4fc7 --- /dev/null +++ b/internal/scaffold/testdata/golden/static-cluster-scoped/mutator.go.golden @@ -0,0 +1,116 @@ +// Package clusterrole provides a builder and resource for managing ClusterRole objects. +package clusterrole + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + rbacv1 "k8s.io/api/rbac/v1" +) + +// Mutation defines a mutation that is applied to the ClusterRole Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + objectEdits []func(*rbacv1.ClusterRole) error +} + +// Mutator is a high-level helper for modifying ClusterRole objects. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the ClusterRole in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Object edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + obj *rbacv1.ClusterRole + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given ClusterRole. +// The constructor creates the initial feature scope automatically. +func NewMutator(obj *rbacv1.ClusterRole) *Mutator { + m := &Mutator{ + obj: obj, + } + m.NextFeature() + return m +} + +// NextFeature advances to a new feature planning scope. All subsequent mutation +// registrations will be grouped into this scope until NextFeature is called again. +// +// The first scope is created automatically by NewMutator. This method is called +// by the framework between mutations to maintain per-feature ordering semantics. +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the ClusterRole's own metadata. +// +// Metadata edits are applied before object edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// Edit records a mutation for the ClusterRole itself. +// +// The edit function receives the object being reconciled and may set any field +// on it. Wrap frequently used edits in named methods on the Mutator so feature +// mutations stay self-documenting, the way the built-in primitives layer typed +// helpers over their editors. Object edits are applied after metadata edits +// within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) Edit(edit func(*rbacv1.ClusterRole) error) { + if edit == nil { + return + } + m.active.objectEdits = append(m.active.objectEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying ClusterRole. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Object edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the ClusterRole as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.obj.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Object edits + for _, edit := range plan.objectEdits { + if err := edit(m.obj); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/scaffold/testdata/golden/static-cluster-scoped/resource.go.golden b/internal/scaffold/testdata/golden/static-cluster-scoped/resource.go.golden new file mode 100644 index 00000000..f1f40cea --- /dev/null +++ b/internal/scaffold/testdata/golden/static-cluster-scoped/resource.go.golden @@ -0,0 +1,111 @@ +package clusterrole + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + rbacv1 "k8s.io/api/rbac/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing ClusterRole objects within a +// controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.Guardable: for conditional reconciliation based on a guard precondition. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// - concepts.DataProducer and concepts.DataConsumer: for declared data topology. +// - concepts.ObservationRecorder: for surfacing live cluster state to declared data extractions on read-only reconciliation. +// - concepts.Previewable: for cluster-free rendering of the desired state. +// - concepts.MutationInspector: for introspecting registered and firing mutations. +type Resource struct { + base *generic.StaticResource[*rbacv1.ClusterRole, *Mutator] +} + +// Identity returns a unique identifier for the ClusterRole in the format +// "rbac.authorization.k8s.io/v1/ClusterRole/". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying ClusterRole object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of the ClusterRole into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// GuardStatus evaluates the resource's guard precondition. +// If no guard was registered, the resource is unconditionally unblocked. +func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) { + return r.base.GuardStatus() +} + +// ExtractData executes all declared data extractions against a deep copy of the +// reconciled ClusterRole. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the ClusterRole. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} + +// ProducedData returns the cells this ClusterRole declares extractions into. +// It satisfies concepts.DataProducer for component topology validation and +// introspection. +func (r *Resource) ProducedData() []concepts.DataCell { + return r.base.ProducedData() +} + +// ConsumedData returns the ClusterRole's declared data reads. It satisfies +// concepts.DataConsumer for component topology validation and introspection. +func (r *Resource) ConsumedData() []concepts.DataConsumption { + return r.base.ConsumedData() +} + +// RecordObservation stores the supplied object as the resource's most recently +// observed cluster state. The framework invokes this on read-only resources after +// fetching them so that declared data extractions observe the live object rather +// than the inert base used to construct the resource. +func (r *Resource) RecordObservation(observed client.Object) error { + return r.base.RecordObservation(observed) +} + +// Preview renders the ClusterRole as a client.Object with feature mutations applied, +// without modifying the resource's internal state. It satisfies the component's +// Previewable capability so the component can assemble a cluster-free preview. +// +// Suspension mutations are not applied; the preview reflects content state only. +// Callers needing the concrete type can type-assert the returned object. +func (r *Resource) Preview() (client.Object, error) { + return r.base.Preview() +} + +// RegisteredMutations returns the deduplicated Names of every mutation registered on +// the ClusterRole, independent of version. It satisfies concepts.MutationInspector so +// the resource can be introspected for version-matrix golden generation. +func (r *Resource) RegisteredMutations() []string { + return r.base.RegisteredMutations() +} + +// FiringSet returns the Names of registered mutations whose gate is enabled for the +// version the ClusterRole was built at. It satisfies concepts.MutationInspector. +func (r *Resource) FiringSet() ([]string, error) { + return r.base.FiringSet() +} + +var _ concepts.MutationInspector = (*Resource)(nil) +var _ concepts.DataProducer = (*Resource)(nil) +var _ concepts.DataConsumer = (*Resource)(nil) diff --git a/internal/scaffold/testdata/golden/static/builder.go.golden b/internal/scaffold/testdata/golden/static/builder.go.golden new file mode 100644 index 00000000..f36dcbd8 --- /dev/null +++ b/internal/scaffold/testdata/golden/static/builder.go.golden @@ -0,0 +1,110 @@ +package configmap + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + corev1 "k8s.io/api/core/v1" +) + +// Builder is a configuration helper for creating and customizing the ConfigMap Resource. +// +// It provides a fluent API for registering mutations and declared data extractions. +// Build() validates the configuration and returns an initialized Resource ready for +// use in a reconciliation loop. +type Builder struct { + base *generic.StaticBuilder[*corev1.ConfigMap, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided ConfigMap object. +// +// The ConfigMap object serves as the desired base state. During reconciliation the +// framework makes the cluster's state match this base state, modified by any +// registered mutations. +// +// The provided ConfigMap must have a Name set and a Namespace set, which is +// validated during the Build() call. +func NewBuilder(obj *corev1.ConfigMap) *Builder { + identityFunc := func(o *corev1.ConfigMap) string { + return fmt.Sprintf("v1/ConfigMap/%s/%s", o.Namespace, o.Name) + } + + base := generic.NewStaticBuilder[*corev1.ConfigMap, *Mutator]( + obj, + identityFunc, + NewMutator, + ) + + return &Builder{ + base: base, + } +} + +// WithMutation registers one or more feature-based mutations for the ConfigMap. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} + +// WithGuard registers a guard precondition that is evaluated before the ConfigMap is +// applied during reconciliation. If the guard returns Blocked, the ConfigMap and all +// resources registered after it are skipped until the guard clears. +// Passing nil clears any previously registered guard. +func (b *Builder) WithGuard( + guard func(corev1.ConfigMap) (concepts.GuardStatusWithReason, error), +) *Builder { + b.base.WithGuard(generic.WrapGuard(guard)) + return b +} + +// WithDataGuard declares that the ConfigMap reads the given data cells and must not +// be applied until every one of them is set. The framework generates the guard and +// its reason (waiting for data ""), and component Build validates that a +// producer for each cell is registered earlier. Data guards are evaluated before any +// custom guard registered with WithGuard. +func (b *Builder) WithDataGuard(cells ...concepts.DataCell) *Builder { + b.base.WithDataGuard(cells...) + return b +} + +// WithOptionalData declares that the ConfigMap reads the given data cells without +// gating on them. Component Build still validates that a producer is registered +// earlier, and the dependency stays visible to introspection. Consumers in this mode +// use Get and skip quietly when a cell is absent. +func (b *Builder) WithOptionalData(cells ...concepts.DataCell) *Builder { + b.base.WithOptionalData(cells...) + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No ConfigMap object was provided. +// - The ConfigMap is missing a Name. +// - The ConfigMap is missing a Namespace. +// - Two registered mutations share a name. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} + +// ExtractInto declares that this ConfigMap produces the value of cell. fn computes +// the value from a copy of the reconciled ConfigMap; the framework stores it in the +// cell and marks it present, immediately after the ConfigMap is applied or fetched. +// Extracting several values means several ExtractInto calls, one per cell. This is a +// package-level function because Go methods cannot introduce the extra type +// parameter V. +func ExtractInto[V any](b *Builder, cell *concepts.Data[V], fn func(corev1.ConfigMap) (V, error)) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} diff --git a/internal/scaffold/testdata/golden/static/builder_test.go.golden b/internal/scaffold/testdata/golden/static/builder_test.go.golden new file mode 100644 index 00000000..83d155af --- /dev/null +++ b/internal/scaffold/testdata/golden/static/builder_test.go.golden @@ -0,0 +1,146 @@ +package configmap + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// testObject returns a valid namespaced ConfigMap fixture. +func testObject() *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object", Namespace: "test-ns"}, + } +} + +func TestBuilderBuildValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj *corev1.ConfigMap + expectedErr string + }{ + { + name: "nil object", + obj: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid object", + obj: testObject(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(tt.obj).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + return + } + + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "v1/ConfigMap/test-ns/test-object", res.Identity()) + }) + } +} + +func TestMutationAppliesThroughMutator(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(testObject()). + WithMutation(Mutation{ + Name: "scaffolded-label", + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("scaffolded-by", "ocf") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + assert.Equal(t, []string{"scaffolded-label"}, res.RegisteredMutations()) + + current := testObject() + require.NoError(t, res.Mutate(current)) + assert.Equal(t, "ocf", current.Labels["scaffolded-by"]) +} + +func TestExtractIntoDeclaredExtraction(t *testing.T) { + t.Parallel() + + cell := concepts.NewData[string]("configmap-name") + builder := NewBuilder(testObject()) + ExtractInto(builder, cell, func(o corev1.ConfigMap) (string, error) { + return o.Name, nil + }) + + res, err := builder.Build() + require.NoError(t, err) + + produced := res.ProducedData() + require.Len(t, produced, 1) + assert.Equal(t, "configmap-name", produced[0].Name()) + + require.NoError(t, res.ExtractData()) + value, ok := cell.Get() + assert.True(t, ok) + assert.Equal(t, "test-object", value) +} + +func TestWithDataGuardAndOptionalDataDeclarations(t *testing.T) { + t.Parallel() + + guarded := concepts.NewData[string]("db-host") + optional := concepts.NewData[string]("db-port") + + res, err := NewBuilder(testObject()). + WithDataGuard(guarded). + WithOptionalData(optional). + Build() + require.NoError(t, err) + + consumed := res.ConsumedData() + require.Len(t, consumed, 2) + assert.Equal(t, "db-host", consumed[0].Cell.Name()) + assert.False(t, consumed[0].Optional) + assert.Equal(t, "db-port", consumed[1].Cell.Name()) + assert.True(t, consumed[1].Optional) + + status, err := res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusBlocked, status.Status) + assert.Equal(t, `waiting for data "db-host"`, status.Reason) + + guarded.Set("postgres.default.svc") + status, err = res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusUnblocked, status.Status) +} diff --git a/internal/scaffold/testdata/golden/static/mutator.go.golden b/internal/scaffold/testdata/golden/static/mutator.go.golden new file mode 100644 index 00000000..42daab9c --- /dev/null +++ b/internal/scaffold/testdata/golden/static/mutator.go.golden @@ -0,0 +1,116 @@ +// Package configmap provides a builder and resource for managing ConfigMap objects. +package configmap + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to the ConfigMap Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + objectEdits []func(*corev1.ConfigMap) error +} + +// Mutator is a high-level helper for modifying ConfigMap objects. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the ConfigMap in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Object edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + obj *corev1.ConfigMap + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given ConfigMap. +// The constructor creates the initial feature scope automatically. +func NewMutator(obj *corev1.ConfigMap) *Mutator { + m := &Mutator{ + obj: obj, + } + m.NextFeature() + return m +} + +// NextFeature advances to a new feature planning scope. All subsequent mutation +// registrations will be grouped into this scope until NextFeature is called again. +// +// The first scope is created automatically by NewMutator. This method is called +// by the framework between mutations to maintain per-feature ordering semantics. +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the ConfigMap's own metadata. +// +// Metadata edits are applied before object edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// Edit records a mutation for the ConfigMap itself. +// +// The edit function receives the object being reconciled and may set any field +// on it. Wrap frequently used edits in named methods on the Mutator so feature +// mutations stay self-documenting, the way the built-in primitives layer typed +// helpers over their editors. Object edits are applied after metadata edits +// within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) Edit(edit func(*corev1.ConfigMap) error) { + if edit == nil { + return + } + m.active.objectEdits = append(m.active.objectEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying ConfigMap. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Object edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the ConfigMap as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.obj.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Object edits + for _, edit := range plan.objectEdits { + if err := edit(m.obj); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/scaffold/testdata/golden/static/resource.go.golden b/internal/scaffold/testdata/golden/static/resource.go.golden new file mode 100644 index 00000000..9a933627 --- /dev/null +++ b/internal/scaffold/testdata/golden/static/resource.go.golden @@ -0,0 +1,111 @@ +package configmap + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing ConfigMap objects within a +// controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.Guardable: for conditional reconciliation based on a guard precondition. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// - concepts.DataProducer and concepts.DataConsumer: for declared data topology. +// - concepts.ObservationRecorder: for surfacing live cluster state to declared data extractions on read-only reconciliation. +// - concepts.Previewable: for cluster-free rendering of the desired state. +// - concepts.MutationInspector: for introspecting registered and firing mutations. +type Resource struct { + base *generic.StaticResource[*corev1.ConfigMap, *Mutator] +} + +// Identity returns a unique identifier for the ConfigMap in the format +// "v1/ConfigMap//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying ConfigMap object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of the ConfigMap into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// GuardStatus evaluates the resource's guard precondition. +// If no guard was registered, the resource is unconditionally unblocked. +func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) { + return r.base.GuardStatus() +} + +// ExtractData executes all declared data extractions against a deep copy of the +// reconciled ConfigMap. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the ConfigMap. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} + +// ProducedData returns the cells this ConfigMap declares extractions into. +// It satisfies concepts.DataProducer for component topology validation and +// introspection. +func (r *Resource) ProducedData() []concepts.DataCell { + return r.base.ProducedData() +} + +// ConsumedData returns the ConfigMap's declared data reads. It satisfies +// concepts.DataConsumer for component topology validation and introspection. +func (r *Resource) ConsumedData() []concepts.DataConsumption { + return r.base.ConsumedData() +} + +// RecordObservation stores the supplied object as the resource's most recently +// observed cluster state. The framework invokes this on read-only resources after +// fetching them so that declared data extractions observe the live object rather +// than the inert base used to construct the resource. +func (r *Resource) RecordObservation(observed client.Object) error { + return r.base.RecordObservation(observed) +} + +// Preview renders the ConfigMap as a client.Object with feature mutations applied, +// without modifying the resource's internal state. It satisfies the component's +// Previewable capability so the component can assemble a cluster-free preview. +// +// Suspension mutations are not applied; the preview reflects content state only. +// Callers needing the concrete type can type-assert the returned object. +func (r *Resource) Preview() (client.Object, error) { + return r.base.Preview() +} + +// RegisteredMutations returns the deduplicated Names of every mutation registered on +// the ConfigMap, independent of version. It satisfies concepts.MutationInspector so +// the resource can be introspected for version-matrix golden generation. +func (r *Resource) RegisteredMutations() []string { + return r.base.RegisteredMutations() +} + +// FiringSet returns the Names of registered mutations whose gate is enabled for the +// version the ConfigMap was built at. It satisfies concepts.MutationInspector. +func (r *Resource) FiringSet() ([]string, error) { + return r.base.FiringSet() +} + +var _ concepts.MutationInspector = (*Resource)(nil) +var _ concepts.DataProducer = (*Resource)(nil) +var _ concepts.DataConsumer = (*Resource)(nil) diff --git a/internal/scaffold/testdata/golden/task/builder.go.golden b/internal/scaffold/testdata/golden/task/builder.go.golden new file mode 100644 index 00000000..c9ab9313 --- /dev/null +++ b/internal/scaffold/testdata/golden/task/builder.go.golden @@ -0,0 +1,202 @@ +package job + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + batchv1 "k8s.io/api/batch/v1" +) + +// DefaultConvergingStatusHandler reports whether the Job has reached its completed state. +// +// This is a scaffolded default: it reports Completed unconditionally, without +// reading the Job's status. Replace it with logic that inspects the fields +// your Job reports its completed state through. +func DefaultConvergingStatusHandler( + _ concepts.ConvergingOperation, _ *batchv1.Job, +) (concepts.CompletionStatusWithReason, error) { + return concepts.CompletionStatusWithReason{ + Status: concepts.CompletionStatusCompleted, + Reason: "Scaffolded default status, replace with Job-specific logic", + }, nil +} + +// DefaultSuspendMutationHandler is the mutation applied to the Job when the +// component is suspended. +// +// This is a scaffolded default: it records no mutation, so the Job is left +// untouched while suspended. Replace it with the change that takes the Job +// out of service, for example scaling to zero or setting a suspended field. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler reports progress towards a suspended state. +// +// This is a scaffolded default: it reports Suspended immediately, matching the +// no-op suspension mutation. Replace it alongside DefaultSuspendMutationHandler so +// the reported progress reflects the mutation you apply. +func DefaultSuspensionStatusHandler(_ *batchv1.Job) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Scaffolded default suspension status, replace with Job-specific logic", + }, nil +} + +// DefaultDeleteOnSuspendHandler decides whether the Job is deleted from the +// cluster when the component is suspended. +// +// This is a scaffolded default: it returns false, so the Job is kept. Return +// true if suspension should remove it instead. +func DefaultDeleteOnSuspendHandler(_ *batchv1.Job) bool { + return false +} + +// Builder is a configuration helper for creating and customizing the Job Resource. +// +// It provides a fluent API for registering mutations, status handlers and declared +// data extractions. Build() validates the configuration and returns an initialized +// Resource ready for use in a reconciliation loop. +type Builder struct { + base *generic.TaskBuilder[*batchv1.Job, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Job object. +// +// The Job object serves as the desired base state. During reconciliation the +// framework makes the cluster's state match this base state, modified by any +// registered mutations. +// +// The provided Job must have a Name set and a Namespace set, which is +// validated during the Build() call. +func NewBuilder(obj *batchv1.Job) *Builder { + identityFunc := func(o *batchv1.Job) string { + return fmt.Sprintf("batch/v1/Job/%s/%s", o.Namespace, o.Name) + } + + base := generic.NewTaskBuilder[*batchv1.Job, *Mutator]( + obj, + identityFunc, + NewMutator, + ) + + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers one or more feature-based mutations for the Job. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining whether the +// Job has reached its completed state. +// +// The default behavior uses DefaultConvergingStatusHandler, which reports Completed +// unconditionally. This handler is required by the generic layer, so it is registered +// in NewBuilder and can only be replaced, never cleared. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *batchv1.Job) (concepts.CompletionStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*batchv1.Job) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Job is modified when the component +// is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler. +func (b *Builder) WithCustomSuspendMutation(handler func(*Mutator) error) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete the +// Job when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler. +func (b *Builder) WithCustomSuspendDeletionDecision(handler func(*batchv1.Job) bool) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithGuard registers a guard precondition that is evaluated before the Job is +// applied during reconciliation. If the guard returns Blocked, the Job and all +// resources registered after it are skipped until the guard clears. +// Passing nil clears any previously registered guard. +func (b *Builder) WithGuard( + guard func(batchv1.Job) (concepts.GuardStatusWithReason, error), +) *Builder { + b.base.WithGuard(generic.WrapGuard(guard)) + return b +} + +// WithDataGuard declares that the Job reads the given data cells and must not +// be applied until every one of them is set. The framework generates the guard and +// its reason (waiting for data ""), and component Build validates that a +// producer for each cell is registered earlier. Data guards are evaluated before any +// custom guard registered with WithGuard. +func (b *Builder) WithDataGuard(cells ...concepts.DataCell) *Builder { + b.base.WithDataGuard(cells...) + return b +} + +// WithOptionalData declares that the Job reads the given data cells without +// gating on them. Component Build still validates that a producer is registered +// earlier, and the dependency stays visible to introspection. Consumers in this mode +// use Get and skip quietly when a cell is absent. +func (b *Builder) WithOptionalData(cells ...concepts.DataCell) *Builder { + b.base.WithOptionalData(cells...) + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Job object was provided. +// - The Job is missing a Name. +// - The Job is missing a Namespace. +// - Two registered mutations share a name. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} + +// ExtractInto declares that this Job produces the value of cell. fn computes +// the value from a copy of the reconciled Job; the framework stores it in the +// cell and marks it present, immediately after the Job is applied or fetched. +// Extracting several values means several ExtractInto calls, one per cell. This is a +// package-level function because Go methods cannot introduce the extra type +// parameter V. +func ExtractInto[V any](b *Builder, cell *concepts.Data[V], fn func(batchv1.Job) (V, error)) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} diff --git a/internal/scaffold/testdata/golden/task/builder_test.go.golden b/internal/scaffold/testdata/golden/task/builder_test.go.golden new file mode 100644 index 00000000..7e300ff1 --- /dev/null +++ b/internal/scaffold/testdata/golden/task/builder_test.go.golden @@ -0,0 +1,146 @@ +package job + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// testObject returns a valid namespaced Job fixture. +func testObject() *batchv1.Job { + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object", Namespace: "test-ns"}, + } +} + +func TestBuilderBuildValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj *batchv1.Job + expectedErr string + }{ + { + name: "nil object", + obj: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + obj: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + obj: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid object", + obj: testObject(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(tt.obj).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + return + } + + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "batch/v1/Job/test-ns/test-object", res.Identity()) + }) + } +} + +func TestMutationAppliesThroughMutator(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(testObject()). + WithMutation(Mutation{ + Name: "scaffolded-label", + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("scaffolded-by", "ocf") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + assert.Equal(t, []string{"scaffolded-label"}, res.RegisteredMutations()) + + current := testObject() + require.NoError(t, res.Mutate(current)) + assert.Equal(t, "ocf", current.Labels["scaffolded-by"]) +} + +func TestExtractIntoDeclaredExtraction(t *testing.T) { + t.Parallel() + + cell := concepts.NewData[string]("job-name") + builder := NewBuilder(testObject()) + ExtractInto(builder, cell, func(o batchv1.Job) (string, error) { + return o.Name, nil + }) + + res, err := builder.Build() + require.NoError(t, err) + + produced := res.ProducedData() + require.Len(t, produced, 1) + assert.Equal(t, "job-name", produced[0].Name()) + + require.NoError(t, res.ExtractData()) + value, ok := cell.Get() + assert.True(t, ok) + assert.Equal(t, "test-object", value) +} + +func TestWithDataGuardAndOptionalDataDeclarations(t *testing.T) { + t.Parallel() + + guarded := concepts.NewData[string]("db-host") + optional := concepts.NewData[string]("db-port") + + res, err := NewBuilder(testObject()). + WithDataGuard(guarded). + WithOptionalData(optional). + Build() + require.NoError(t, err) + + consumed := res.ConsumedData() + require.Len(t, consumed, 2) + assert.Equal(t, "db-host", consumed[0].Cell.Name()) + assert.False(t, consumed[0].Optional) + assert.Equal(t, "db-port", consumed[1].Cell.Name()) + assert.True(t, consumed[1].Optional) + + status, err := res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusBlocked, status.Status) + assert.Equal(t, `waiting for data "db-host"`, status.Reason) + + guarded.Set("postgres.default.svc") + status, err = res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusUnblocked, status.Status) +} diff --git a/internal/scaffold/testdata/golden/task/mutator.go.golden b/internal/scaffold/testdata/golden/task/mutator.go.golden new file mode 100644 index 00000000..be753b5d --- /dev/null +++ b/internal/scaffold/testdata/golden/task/mutator.go.golden @@ -0,0 +1,116 @@ +// Package job provides a builder and resource for managing Job objects. +package job + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + batchv1 "k8s.io/api/batch/v1" +) + +// Mutation defines a mutation that is applied to the Job Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + objectEdits []func(*batchv1.Job) error +} + +// Mutator is a high-level helper for modifying Job objects. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Job in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Object edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + obj *batchv1.Job + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Job. +// The constructor creates the initial feature scope automatically. +func NewMutator(obj *batchv1.Job) *Mutator { + m := &Mutator{ + obj: obj, + } + m.NextFeature() + return m +} + +// NextFeature advances to a new feature planning scope. All subsequent mutation +// registrations will be grouped into this scope until NextFeature is called again. +// +// The first scope is created automatically by NewMutator. This method is called +// by the framework between mutations to maintain per-feature ordering semantics. +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Job's own metadata. +// +// Metadata edits are applied before object edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// Edit records a mutation for the Job itself. +// +// The edit function receives the object being reconciled and may set any field +// on it. Wrap frequently used edits in named methods on the Mutator so feature +// mutations stay self-documenting, the way the built-in primitives layer typed +// helpers over their editors. Object edits are applied after metadata edits +// within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) Edit(edit func(*batchv1.Job) error) { + if edit == nil { + return + } + m.active.objectEdits = append(m.active.objectEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying Job. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Object edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Job as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.obj.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Object edits + for _, edit := range plan.objectEdits { + if err := edit(m.obj); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/scaffold/testdata/golden/task/resource.go.golden b/internal/scaffold/testdata/golden/task/resource.go.golden new file mode 100644 index 00000000..bd3b253e --- /dev/null +++ b/internal/scaffold/testdata/golden/task/resource.go.golden @@ -0,0 +1,146 @@ +package job + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + batchv1 "k8s.io/api/batch/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing Job objects within a +// controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.Completable: for run-to-completion tracking. +// - concepts.Suspendable: for temporary deactivation. +// - concepts.Guardable: for conditional reconciliation based on a guard precondition. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// - concepts.DataProducer and concepts.DataConsumer: for declared data topology. +// - concepts.ObservationRecorder: for surfacing live cluster state to declared data extractions on read-only reconciliation. +// - concepts.Previewable: for cluster-free rendering of the desired state. +// - concepts.MutationInspector: for introspecting registered and firing mutations. +type Resource struct { + base *generic.TaskResource[*batchv1.Job, *Mutator] +} + +// Identity returns a unique identifier for the Job in the format +// "batch/v1/Job//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Job object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of the Job into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// 3. Suspension: if the resource is suspending, the suspension mutation is applied. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates whether the Job has reached its completed state. +// +// By default it uses DefaultConvergingStatusHandler. The return value carries a status and +// a human-readable reason, both surfaced in the component's conditions. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.CompletionStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the Job is deleted from the cluster when +// the parent component is suspended. +// +// By default it uses DefaultDeleteOnSuspendHandler. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the Job. +// +// It registers a mutation executed during the next Mutate call. By default it uses +// DefaultSuspendMutationHandler. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default it uses DefaultSuspensionStatusHandler. The framework uses it to decide +// when the component has reached a fully suspended state. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// GuardStatus evaluates the resource's guard precondition. +// If no guard was registered, the resource is unconditionally unblocked. +func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) { + return r.base.GuardStatus() +} + +// ExtractData executes all declared data extractions against a deep copy of the +// reconciled Job. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the Job. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} + +// ProducedData returns the cells this Job declares extractions into. +// It satisfies concepts.DataProducer for component topology validation and +// introspection. +func (r *Resource) ProducedData() []concepts.DataCell { + return r.base.ProducedData() +} + +// ConsumedData returns the Job's declared data reads. It satisfies +// concepts.DataConsumer for component topology validation and introspection. +func (r *Resource) ConsumedData() []concepts.DataConsumption { + return r.base.ConsumedData() +} + +// RecordObservation stores the supplied object as the resource's most recently +// observed cluster state. The framework invokes this on read-only resources after +// fetching them so that declared data extractions observe the live object rather +// than the inert base used to construct the resource. +func (r *Resource) RecordObservation(observed client.Object) error { + return r.base.RecordObservation(observed) +} + +// Preview renders the Job as a client.Object with feature mutations applied, +// without modifying the resource's internal state. It satisfies the component's +// Previewable capability so the component can assemble a cluster-free preview. +// +// Suspension mutations are not applied; the preview reflects content state only. +// Callers needing the concrete type can type-assert the returned object. +func (r *Resource) Preview() (client.Object, error) { + return r.base.Preview() +} + +// RegisteredMutations returns the deduplicated Names of every mutation registered on +// the Job, independent of version. It satisfies concepts.MutationInspector so +// the resource can be introspected for version-matrix golden generation. +func (r *Resource) RegisteredMutations() []string { + return r.base.RegisteredMutations() +} + +// FiringSet returns the Names of registered mutations whose gate is enabled for the +// version the Job was built at. It satisfies concepts.MutationInspector. +func (r *Resource) FiringSet() ([]string, error) { + return r.base.FiringSet() +} + +var _ concepts.MutationInspector = (*Resource)(nil) +var _ concepts.DataProducer = (*Resource)(nil) +var _ concepts.DataConsumer = (*Resource)(nil) diff --git a/internal/scaffold/testdata/golden/workload/builder.go.golden b/internal/scaffold/testdata/golden/workload/builder.go.golden new file mode 100644 index 00000000..c9a44c61 --- /dev/null +++ b/internal/scaffold/testdata/golden/workload/builder.go.golden @@ -0,0 +1,228 @@ +package deployment + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + appsv1 "k8s.io/api/apps/v1" +) + +// DefaultConvergingStatusHandler reports whether the Deployment has reached its converged state. +// +// This is a scaffolded default: it reports Healthy unconditionally, without +// reading the Deployment's status. Replace it with logic that inspects the fields +// your Deployment reports its converged state through. +func DefaultConvergingStatusHandler( + _ concepts.ConvergingOperation, _ *appsv1.Deployment, +) (concepts.AliveStatusWithReason, error) { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "Scaffolded default status, replace with Deployment-specific logic", + }, nil +} + +// DefaultGraceStatusHandler reports the Deployment's health once the component's grace +// period has expired. +// +// This is a scaffolded default: it reports Healthy unconditionally. Replace it with +// logic that distinguishes Healthy, Degraded, and Down for your Deployment, and keep it +// consistent with DefaultConvergingStatusHandler: grace must not report Healthy for a state +// the status handler considers unhealthy. +func DefaultGraceStatusHandler(_ *appsv1.Deployment) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusHealthy, + Reason: "Scaffolded default grace status, replace with Deployment-specific logic", + }, nil +} + +// DefaultSuspendMutationHandler is the mutation applied to the Deployment when the +// component is suspended. +// +// This is a scaffolded default: it records no mutation, so the Deployment is left +// untouched while suspended. Replace it with the change that takes the Deployment +// out of service, for example scaling to zero or setting a suspended field. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler reports progress towards a suspended state. +// +// This is a scaffolded default: it reports Suspended immediately, matching the +// no-op suspension mutation. Replace it alongside DefaultSuspendMutationHandler so +// the reported progress reflects the mutation you apply. +func DefaultSuspensionStatusHandler(_ *appsv1.Deployment) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Scaffolded default suspension status, replace with Deployment-specific logic", + }, nil +} + +// DefaultDeleteOnSuspendHandler decides whether the Deployment is deleted from the +// cluster when the component is suspended. +// +// This is a scaffolded default: it returns false, so the Deployment is kept. Return +// true if suspension should remove it instead. +func DefaultDeleteOnSuspendHandler(_ *appsv1.Deployment) bool { + return false +} + +// Builder is a configuration helper for creating and customizing the Deployment Resource. +// +// It provides a fluent API for registering mutations, status handlers and declared +// data extractions. Build() validates the configuration and returns an initialized +// Resource ready for use in a reconciliation loop. +type Builder struct { + base *generic.WorkloadBuilder[*appsv1.Deployment, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Deployment object. +// +// The Deployment object serves as the desired base state. During reconciliation the +// framework makes the cluster's state match this base state, modified by any +// registered mutations. +// +// The provided Deployment must have a Name set and a Namespace set, which is +// validated during the Build() call. +func NewBuilder(obj *appsv1.Deployment) *Builder { + identityFunc := func(o *appsv1.Deployment) string { + return fmt.Sprintf("apps/v1/Deployment/%s/%s", o.Namespace, o.Name) + } + + base := generic.NewWorkloadBuilder[*appsv1.Deployment, *Mutator]( + obj, + identityFunc, + NewMutator, + ) + + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomGraceStatus(DefaultGraceStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers one or more feature-based mutations for the Deployment. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(ms ...Mutation) *Builder { + for _, m := range ms { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + } + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining whether the +// Deployment has reached its converged state. +// +// The default behavior uses DefaultConvergingStatusHandler, which reports Healthy +// unconditionally. This handler is required by the generic layer, so it is registered +// in NewBuilder and can only be replaced, never cleared. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *appsv1.Deployment) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the Deployment reports its health once the +// component's grace period has expired. +// +// The default behavior uses DefaultGraceStatusHandler. +func (b *Builder) WithCustomGraceStatus( + handler func(*appsv1.Deployment) (concepts.GraceStatusWithReason, error), +) *Builder { + b.base.WithCustomGraceStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*appsv1.Deployment) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Deployment is modified when the component +// is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler. +func (b *Builder) WithCustomSuspendMutation(handler func(*Mutator) error) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete the +// Deployment when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler. +func (b *Builder) WithCustomSuspendDeletionDecision(handler func(*appsv1.Deployment) bool) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithGuard registers a guard precondition that is evaluated before the Deployment is +// applied during reconciliation. If the guard returns Blocked, the Deployment and all +// resources registered after it are skipped until the guard clears. +// Passing nil clears any previously registered guard. +func (b *Builder) WithGuard( + guard func(appsv1.Deployment) (concepts.GuardStatusWithReason, error), +) *Builder { + b.base.WithGuard(generic.WrapGuard(guard)) + return b +} + +// WithDataGuard declares that the Deployment reads the given data cells and must not +// be applied until every one of them is set. The framework generates the guard and +// its reason (waiting for data ""), and component Build validates that a +// producer for each cell is registered earlier. Data guards are evaluated before any +// custom guard registered with WithGuard. +func (b *Builder) WithDataGuard(cells ...concepts.DataCell) *Builder { + b.base.WithDataGuard(cells...) + return b +} + +// WithOptionalData declares that the Deployment reads the given data cells without +// gating on them. Component Build still validates that a producer is registered +// earlier, and the dependency stays visible to introspection. Consumers in this mode +// use Get and skip quietly when a cell is absent. +func (b *Builder) WithOptionalData(cells ...concepts.DataCell) *Builder { + b.base.WithOptionalData(cells...) + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Deployment object was provided. +// - The Deployment is missing a Name. +// - The Deployment is missing a Namespace. +// - Two registered mutations share a name. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} + +// ExtractInto declares that this Deployment produces the value of cell. fn computes +// the value from a copy of the reconciled Deployment; the framework stores it in the +// cell and marks it present, immediately after the Deployment is applied or fetched. +// Extracting several values means several ExtractInto calls, one per cell. This is a +// package-level function because Go methods cannot introduce the extra type +// parameter V. +func ExtractInto[V any](b *Builder, cell *concepts.Data[V], fn func(appsv1.Deployment) (V, error)) { + generic.ExtractInto(&b.base.BaseBuilder, cell, generic.WrapExtraction(fn)) +} diff --git a/internal/scaffold/testdata/golden/workload/builder_test.go.golden b/internal/scaffold/testdata/golden/workload/builder_test.go.golden new file mode 100644 index 00000000..74179a45 --- /dev/null +++ b/internal/scaffold/testdata/golden/workload/builder_test.go.golden @@ -0,0 +1,146 @@ +package deployment + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// testObject returns a valid namespaced Deployment fixture. +func testObject() *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object", Namespace: "test-ns"}, + } +} + +func TestBuilderBuildValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj *appsv1.Deployment + expectedErr string + }{ + { + name: "nil object", + obj: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + obj: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + obj: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-object"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid object", + obj: testObject(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(tt.obj).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + return + } + + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "apps/v1/Deployment/test-ns/test-object", res.Identity()) + }) + } +} + +func TestMutationAppliesThroughMutator(t *testing.T) { + t.Parallel() + + res, err := NewBuilder(testObject()). + WithMutation(Mutation{ + Name: "scaffolded-label", + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("scaffolded-by", "ocf") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + assert.Equal(t, []string{"scaffolded-label"}, res.RegisteredMutations()) + + current := testObject() + require.NoError(t, res.Mutate(current)) + assert.Equal(t, "ocf", current.Labels["scaffolded-by"]) +} + +func TestExtractIntoDeclaredExtraction(t *testing.T) { + t.Parallel() + + cell := concepts.NewData[string]("deployment-name") + builder := NewBuilder(testObject()) + ExtractInto(builder, cell, func(o appsv1.Deployment) (string, error) { + return o.Name, nil + }) + + res, err := builder.Build() + require.NoError(t, err) + + produced := res.ProducedData() + require.Len(t, produced, 1) + assert.Equal(t, "deployment-name", produced[0].Name()) + + require.NoError(t, res.ExtractData()) + value, ok := cell.Get() + assert.True(t, ok) + assert.Equal(t, "test-object", value) +} + +func TestWithDataGuardAndOptionalDataDeclarations(t *testing.T) { + t.Parallel() + + guarded := concepts.NewData[string]("db-host") + optional := concepts.NewData[string]("db-port") + + res, err := NewBuilder(testObject()). + WithDataGuard(guarded). + WithOptionalData(optional). + Build() + require.NoError(t, err) + + consumed := res.ConsumedData() + require.Len(t, consumed, 2) + assert.Equal(t, "db-host", consumed[0].Cell.Name()) + assert.False(t, consumed[0].Optional) + assert.Equal(t, "db-port", consumed[1].Cell.Name()) + assert.True(t, consumed[1].Optional) + + status, err := res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusBlocked, status.Status) + assert.Equal(t, `waiting for data "db-host"`, status.Reason) + + guarded.Set("postgres.default.svc") + status, err = res.GuardStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GuardStatusUnblocked, status.Status) +} diff --git a/internal/scaffold/testdata/golden/workload/mutator.go.golden b/internal/scaffold/testdata/golden/workload/mutator.go.golden new file mode 100644 index 00000000..a6a304e0 --- /dev/null +++ b/internal/scaffold/testdata/golden/workload/mutator.go.golden @@ -0,0 +1,116 @@ +// Package deployment provides a builder and resource for managing Deployment objects. +package deployment + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + appsv1 "k8s.io/api/apps/v1" +) + +// Mutation defines a mutation that is applied to the Deployment Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + objectEdits []func(*appsv1.Deployment) error +} + +// Mutator is a high-level helper for modifying Deployment objects. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Deployment in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Object edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + obj *appsv1.Deployment + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Deployment. +// The constructor creates the initial feature scope automatically. +func NewMutator(obj *appsv1.Deployment) *Mutator { + m := &Mutator{ + obj: obj, + } + m.NextFeature() + return m +} + +// NextFeature advances to a new feature planning scope. All subsequent mutation +// registrations will be grouped into this scope until NextFeature is called again. +// +// The first scope is created automatically by NewMutator. This method is called +// by the framework between mutations to maintain per-feature ordering semantics. +func (m *Mutator) NextFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Deployment's own metadata. +// +// Metadata edits are applied before object edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// Edit records a mutation for the Deployment itself. +// +// The edit function receives the object being reconciled and may set any field +// on it. Wrap frequently used edits in named methods on the Mutator so feature +// mutations stay self-documenting, the way the built-in primitives layer typed +// helpers over their editors. Object edits are applied after metadata edits +// within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) Edit(edit func(*appsv1.Deployment) error) { + if edit == nil { + return + } + m.active.objectEdits = append(m.active.objectEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying Deployment. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Object edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Deployment as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.obj.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Object edits + for _, edit := range plan.objectEdits { + if err := edit(m.obj); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/scaffold/testdata/golden/workload/resource.go.golden b/internal/scaffold/testdata/golden/workload/resource.go.golden new file mode 100644 index 00000000..f92b4169 --- /dev/null +++ b/internal/scaffold/testdata/golden/workload/resource.go.golden @@ -0,0 +1,155 @@ +package deployment + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing Deployment objects within a +// controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.Alive: for health and readiness tracking. +// - concepts.Graceful: for health reporting once the grace period expires. +// - concepts.Suspendable: for temporary deactivation. +// - concepts.Guardable: for conditional reconciliation based on a guard precondition. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// - concepts.DataProducer and concepts.DataConsumer: for declared data topology. +// - concepts.ObservationRecorder: for surfacing live cluster state to declared data extractions on read-only reconciliation. +// - concepts.Previewable: for cluster-free rendering of the desired state. +// - concepts.MutationInspector: for introspecting registered and firing mutations. +type Resource struct { + base *generic.WorkloadResource[*appsv1.Deployment, *Mutator] +} + +// Identity returns a unique identifier for the Deployment in the format +// "apps/v1/Deployment//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Deployment object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of the Deployment into the desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// 3. Suspension: if the resource is suspending, the suspension mutation is applied. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates whether the Deployment has reached its converged state. +// +// By default it uses DefaultConvergingStatusHandler. The return value carries a status and +// a human-readable reason, both surfaced in the component's conditions. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.AliveStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// GraceStatus provides a health assessment of the Deployment once the component's +// grace period has expired. +// +// By default it uses DefaultGraceStatusHandler. +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} + +// DeleteOnSuspend determines whether the Deployment is deleted from the cluster when +// the parent component is suspended. +// +// By default it uses DefaultDeleteOnSuspendHandler. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the Deployment. +// +// It registers a mutation executed during the next Mutate call. By default it uses +// DefaultSuspendMutationHandler. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default it uses DefaultSuspensionStatusHandler. The framework uses it to decide +// when the component has reached a fully suspended state. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// GuardStatus evaluates the resource's guard precondition. +// If no guard was registered, the resource is unconditionally unblocked. +func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) { + return r.base.GuardStatus() +} + +// ExtractData executes all declared data extractions against a deep copy of the +// reconciled Deployment. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the Deployment. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} + +// ProducedData returns the cells this Deployment declares extractions into. +// It satisfies concepts.DataProducer for component topology validation and +// introspection. +func (r *Resource) ProducedData() []concepts.DataCell { + return r.base.ProducedData() +} + +// ConsumedData returns the Deployment's declared data reads. It satisfies +// concepts.DataConsumer for component topology validation and introspection. +func (r *Resource) ConsumedData() []concepts.DataConsumption { + return r.base.ConsumedData() +} + +// RecordObservation stores the supplied object as the resource's most recently +// observed cluster state. The framework invokes this on read-only resources after +// fetching them so that declared data extractions observe the live object rather +// than the inert base used to construct the resource. +func (r *Resource) RecordObservation(observed client.Object) error { + return r.base.RecordObservation(observed) +} + +// Preview renders the Deployment as a client.Object with feature mutations applied, +// without modifying the resource's internal state. It satisfies the component's +// Previewable capability so the component can assemble a cluster-free preview. +// +// Suspension mutations are not applied; the preview reflects content state only. +// Callers needing the concrete type can type-assert the returned object. +func (r *Resource) Preview() (client.Object, error) { + return r.base.Preview() +} + +// RegisteredMutations returns the deduplicated Names of every mutation registered on +// the Deployment, independent of version. It satisfies concepts.MutationInspector so +// the resource can be introspected for version-matrix golden generation. +func (r *Resource) RegisteredMutations() []string { + return r.base.RegisteredMutations() +} + +// FiringSet returns the Names of registered mutations whose gate is enabled for the +// version the Deployment was built at. It satisfies concepts.MutationInspector. +func (r *Resource) FiringSet() ([]string, error) { + return r.base.FiringSet() +} + +var _ concepts.MutationInspector = (*Resource)(nil) +var _ concepts.DataProducer = (*Resource)(nil) +var _ concepts.DataConsumer = (*Resource)(nil) diff --git a/internal/scaffold/variant.go b/internal/scaffold/variant.go new file mode 100644 index 00000000..6db66886 --- /dev/null +++ b/internal/scaffold/variant.go @@ -0,0 +1,134 @@ +// Package scaffold renders custom-resource wrapper packages from embedded templates. +package scaffold + +// Variant identifies which resource category a generated wrapper belongs to. +type Variant string + +// The four resource categories the framework defines. +const ( + // VariantStatic is a configuration object with no runtime health semantics. + VariantStatic Variant = "static" + // VariantWorkload is a long-running process with replica-based health. + VariantWorkload Variant = "workload" + // VariantTask is a run-to-completion workload. + VariantTask Variant = "task" + // VariantIntegration is an external-dependency object such as a Service or Ingress. + VariantIntegration Variant = "integration" +) + +// Variants lists every supported variant in flag-documentation order. +var Variants = []Variant{VariantStatic, VariantWorkload, VariantTask, VariantIntegration} + +// convergingStatusMethod is the resource method every status-bearing variant +// forwards its status through. Only the result type differs per variant. +const convergingStatusMethod = "ConvergingStatus" + +// VariantSpec describes how a variant wires into pkg/generic. Templates read it +// instead of branching on the variant name. +type VariantSpec struct { + // GenericBuilder is the pkg/generic builder type, for example "WorkloadBuilder". + GenericBuilder string + // GenericConstructor is the pkg/generic builder constructor, for example "NewWorkloadBuilder". + GenericConstructor string + // GenericResource is the pkg/generic resource type, for example "WorkloadResource". + GenericResource string + // HasStatus reports whether the variant has a required status handler. + HasStatus bool + // StatusSetter is the builder method registering the status handler. + StatusSetter string + // StatusMethod is the resource method forwarding the status, always + // convergingStatusMethod. + StatusMethod string + // StatusResult is the qualified status result type. + StatusResult string + // StatusHandler is the generated default handler's name. + StatusHandler string + // StatusConstant is the qualified healthy status constant the default reports. + StatusConstant string + // StatusValue is the runtime string value of StatusConstant. + StatusValue string + // StatusNoun names the state the handler reports on, used in GoDoc. + StatusNoun string + // HasGrace reports whether the variant supports a grace status handler. + HasGrace bool + // HasSuspension reports whether the variant supports suspension handlers. + HasSuspension bool + // LifecycleInterfaces are the variant-specific bullets of the generated + // Resource's "It implements the following component interfaces" list, each + // rendered as ": ." after the component.Resource + // bullet and before the ones every variant shares. + LifecycleInterfaces []string +} + +// Spec returns the generic-layer wiring for the variant. The zero VariantSpec is +// returned for an unknown variant; Options.Resolve rejects those before rendering. +func (v Variant) Spec() VariantSpec { + switch v { + case VariantStatic: + return VariantSpec{ + GenericBuilder: "StaticBuilder", + GenericConstructor: "NewStaticBuilder", + GenericResource: "StaticResource", + } + case VariantWorkload: + return VariantSpec{ + GenericBuilder: "WorkloadBuilder", + GenericConstructor: "NewWorkloadBuilder", + GenericResource: "WorkloadResource", + HasStatus: true, + StatusSetter: "WithCustomConvergeStatus", + StatusMethod: convergingStatusMethod, + StatusResult: "concepts.AliveStatusWithReason", + StatusHandler: "DefaultConvergingStatusHandler", + StatusConstant: "concepts.AliveConvergingStatusHealthy", + StatusValue: "Healthy", + StatusNoun: "converged", + HasGrace: true, + HasSuspension: true, + LifecycleInterfaces: []string{ + "concepts.Alive: for health and readiness tracking.", + "concepts.Graceful: for health reporting once the grace period expires.", + }, + } + case VariantTask: + return VariantSpec{ + GenericBuilder: "TaskBuilder", + GenericConstructor: "NewTaskBuilder", + GenericResource: "TaskResource", + HasStatus: true, + StatusSetter: "WithCustomConvergeStatus", + StatusMethod: convergingStatusMethod, + StatusResult: "concepts.CompletionStatusWithReason", + StatusHandler: "DefaultConvergingStatusHandler", + StatusConstant: "concepts.CompletionStatusCompleted", + StatusValue: "Completed", + StatusNoun: "completed", + HasSuspension: true, + LifecycleInterfaces: []string{ + "concepts.Completable: for run-to-completion tracking.", + }, + } + case VariantIntegration: + return VariantSpec{ + GenericBuilder: "IntegrationBuilder", + GenericConstructor: "NewIntegrationBuilder", + GenericResource: "IntegrationResource", + HasStatus: true, + StatusSetter: "WithCustomOperationalStatus", + StatusMethod: convergingStatusMethod, + StatusResult: "concepts.OperationalStatusWithReason", + StatusHandler: "DefaultOperationalStatusHandler", + StatusConstant: "concepts.OperationalStatusOperational", + StatusValue: "Operational", + StatusNoun: "operational", + HasGrace: true, + HasSuspension: true, + LifecycleInterfaces: []string{ + "concepts.Operational: for external-dependency readiness tracking.", + "concepts.Graceful: for health reporting once the grace period expires.", + }, + } + default: + return VariantSpec{} + } +} diff --git a/mkdocs.yml b/mkdocs.yml index e8fee6e8..6d751653 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,7 @@ nav: - Escape Hatch: - Unstructured: primitives/unstructured.md - Guides: + - CLI: cli.md - Guidelines: guidelines.md - Testing: testing.md - Reference: diff --git a/pkg/component/data.go b/pkg/component/data.go index e91f061f..79e8a9ab 100644 --- a/pkg/component/data.go +++ b/pkg/component/data.go @@ -17,7 +17,7 @@ func isNilCell(cell concepts.DataCell) bool { } v := reflect.ValueOf(cell) switch v.Kind() { - case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.Interface: + case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.Interface: return v.IsNil() default: return false diff --git a/plugin/commands/new-wrapper.md b/plugin/commands/new-wrapper.md index fe8b1d0d..e174fc3b 100644 --- a/plugin/commands/new-wrapper.md +++ b/plugin/commands/new-wrapper.md @@ -18,9 +18,16 @@ First invoke the `ocf:custom-resource-wrappers` skill and follow it. Then: - The Go type and import path of the custom resource. - The resource category (this decides which status handlers the wrapper implements). - Namespaced or cluster-scoped. -5. Implement the wrapper following the eight steps in the skill, in order: category, mutation type alias, mutator, - status handlers, builder, resource, feature mutations, component registration. Match the layout of any existing - wrapper in this repository if one exists. -6. Verify exact framework signatures with `go doc github.com/sourcehawk/operator-component-framework/pkg/generic` before +5. Generate the package with the framework CLI rather than writing it by hand: + `ocf scaffold wrapper --type . --variant --group `, adding + `--cluster-scoped` for a cluster-scoped kind. Install it first if it is missing + (`go install github.com/sourcehawk/operator-component-framework/cmd/ocf@latest`), and run `go mod tidy` afterwards so + the wrapped type resolves. If the CLI cannot be installed, or the package already exists and is being extended, + implement the wrapper by hand following the eight steps in the skill, in order: category, mutation type alias, + mutator, status handlers, builder, resource, feature mutations, component registration. Match the layout of any + existing wrapper in this repository if one exists. +6. Replace the scaffolded defaults with kind-specific logic: the status handlers report healthy, completed, or + operational unconditionally, and the suspension handlers are no-ops. Each carries a comment saying so. +7. Verify exact framework signatures with `go doc github.com/sourcehawk/operator-component-framework/pkg/generic` before finalizing; do not invent interfaces. -7. Write tests per the `ocf:testing-operators` skill, build, run the project's tests, and report what was created. +8. Write tests per the `ocf:testing-operators` skill, build, run the project's tests, and report what was created. diff --git a/plugin/skills/custom-resource-wrappers/SKILL.md b/plugin/skills/custom-resource-wrappers/SKILL.md index 2302ae66..55711d87 100644 --- a/plugin/skills/custom-resource-wrappers/SKILL.md +++ b/plugin/skills/custom-resource-wrappers/SKILL.md @@ -25,6 +25,24 @@ API is not needed for a kind the operator touches only occasionally. Write a ful the kind is managed often enough to justify a dedicated package. Other unstructured variants exist per resource category; see the using-primitives skill for the full set. +## Generate the package first + +The framework ships a CLI that generates this whole pattern: + +```bash +go install github.com/sourcehawk/operator-component-framework/cmd/ocf@latest +ocf scaffold wrapper --type . --variant --group +``` + +It writes `mutator.go`, `builder.go`, `resource.go`, and `builder_test.go` into `./`, wired to the framework +version the CLI was built from, with working default status handlers marked as scaffolded defaults to replace. Prefer it +over writing the files by hand: the boilerplate below is what it produces, so the remaining work is replacing those +defaults with kind-specific logic. + +Run `go mod tidy` afterwards if the module does not already depend on the wrapped type's API package, since the CLI +never edits `go.mod`. The steps below stay the reference for what the generated code means, and for the cases the CLI +does not cover: an existing wrapper being extended, or a kind whose scaffold has already been customized. + A custom resource is three wrapped pieces: the builder configures and validates, producing a resource; the resource delegates lifecycle methods to a generic base; the mutator records and applies changes to the Kubernetes object. diff --git a/plugin/skills/custom-resource-wrappers/references/custom-resource.md b/plugin/skills/custom-resource-wrappers/references/custom-resource.md index 80e97a91..ed36922b 100644 --- a/plugin/skills/custom-resource-wrappers/references/custom-resource.md +++ b/plugin/skills/custom-resource-wrappers/references/custom-resource.md @@ -18,6 +18,12 @@ status, and mutator logic, exactly the way the built-in primitives do. (`pkg/primitives/unstructured/static`). See [Unstructured Primitives](primitives.md#unstructured-primitives). This guide covers the wrapper pattern, which gives you a typed, self-documenting API for a kind you manage often. +!!! tip "Generate this pattern" + + `ocf scaffold wrapper` generates the complete package this page describes: mutator, builder, resource, and tests, + compiling and passing on a fresh scaffold. See the [CLI](cli.md) guide. This page stays the reference for what the + generated code means and what to replace in it. + --- ## Steps