From 1e9dac56eaa5bfb04e2ee790819575a4cac626ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:13:29 +0200 Subject: [PATCH 01/22] feat(cli): add ocf command skeleton with version subcommand --- cmd/ocf/cli_test.go | 79 +++++++++++++++++++++++++++++++++++++++++++++ cmd/ocf/main.go | 10 ++++++ cmd/ocf/root.go | 19 +++++++++++ cmd/ocf/scaffold.go | 12 +++++++ cmd/ocf/version.go | 35 ++++++++++++++++++++ go.mod | 2 ++ go.sum | 30 ++++------------- 7 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 cmd/ocf/cli_test.go create mode 100644 cmd/ocf/main.go create mode 100644 cmd/ocf/root.go create mode 100644 cmd/ocf/scaffold.go create mode 100644 cmd/ocf/version.go diff --git a/cmd/ocf/cli_test.go b/cmd/ocf/cli_test.go new file mode 100644 index 00000000..3fd454d2 --- /dev/null +++ b/cmd/ocf/cli_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + "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 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..2ef16b2e --- /dev/null +++ b/cmd/ocf/scaffold.go @@ -0,0 +1,12 @@ +package main + +import "github.com/spf13/cobra" + +// newScaffoldCommand builds the scaffold subcommand group. +func newScaffoldCommand() *cobra.Command { + return &cobra.Command{ + Use: "scaffold", + Short: "Generate framework code from embedded templates", + Args: cobra.NoArgs, + } +} 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/go.mod b/go.mod index ac1a3807..1e7aeed2 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/onsi/gomega v1.41.0 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 3dbe0515..05ba91b4 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,12 +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/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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -110,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= @@ -147,36 +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/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/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/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/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/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/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= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= @@ -205,8 +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= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= From 338fb712f97cc92990ddef74688be27f87cabd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:19:25 +0200 Subject: [PATCH 02/22] feat(scaffold): add wrapper option validation and derivation --- internal/scaffold/data.go | 68 ++++++++ internal/scaffold/options.go | 205 ++++++++++++++++++++++++ internal/scaffold/options_test.go | 256 ++++++++++++++++++++++++++++++ internal/scaffold/variant.go | 113 +++++++++++++ 4 files changed, 642 insertions(+) create mode 100644 internal/scaffold/data.go create mode 100644 internal/scaffold/options.go create mode 100644 internal/scaffold/options_test.go create mode 100644 internal/scaffold/variant.go 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/options.go b/internal/scaffold/options.go new file mode 100644 index 00000000..2b73baf3 --- /dev/null +++ b/internal/scaffold/options.go @@ -0,0 +1,205 @@ +package scaffold + +import ( + "fmt" + "go/token" + "regexp" + "strings" +) + +var ( + apiVersionPattern = regexp.MustCompile(`^v[0-9]+((alpha|beta)[0-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)`) + } + + lastSegment := lastPathSegment(importPath) + + version := o.Version + if version == "" { + 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 + } + + 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 !exportedNamePattern.MatchString(typeName) { + return "", "", fmt.Errorf("--type type name %q must be an exported Go identifier", typeName) + } + + return importPath, typeName, nil +} + +// 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..580427d0 --- /dev/null +++ b/internal/scaffold/options_test.go @@ -0,0 +1,256 @@ +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: "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 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: "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: "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) + + 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) + + 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) + + 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) +} diff --git a/internal/scaffold/variant.go b/internal/scaffold/variant.go new file mode 100644 index 00000000..b7f9920f --- /dev/null +++ b/internal/scaffold/variant.go @@ -0,0 +1,113 @@ +// 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} + +// 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 "ConvergingStatus". + 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 +} + +// 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: "ConvergingStatus", + StatusResult: "concepts.AliveStatusWithReason", + StatusHandler: "DefaultConvergingStatusHandler", + StatusConstant: "concepts.AliveConvergingStatusHealthy", + StatusValue: "Healthy", + StatusNoun: "converged", + HasGrace: true, + HasSuspension: true, + } + case VariantTask: + return VariantSpec{ + GenericBuilder: "TaskBuilder", + GenericConstructor: "NewTaskBuilder", + GenericResource: "TaskResource", + HasStatus: true, + StatusSetter: "WithCustomConvergeStatus", + StatusMethod: "ConvergingStatus", + StatusResult: "concepts.CompletionStatusWithReason", + StatusHandler: "DefaultConvergingStatusHandler", + StatusConstant: "concepts.CompletionStatusCompleted", + StatusValue: "Completed", + StatusNoun: "completed", + HasSuspension: true, + } + case VariantIntegration: + return VariantSpec{ + GenericBuilder: "IntegrationBuilder", + GenericConstructor: "NewIntegrationBuilder", + GenericResource: "IntegrationResource", + HasStatus: true, + StatusSetter: "WithCustomOperationalStatus", + StatusMethod: "ConvergingStatus", + StatusResult: "concepts.OperationalStatusWithReason", + StatusHandler: "DefaultOperationalStatusHandler", + StatusConstant: "concepts.OperationalStatusOperational", + StatusValue: "Operational", + StatusNoun: "operational", + HasGrace: true, + HasSuspension: true, + } + default: + return VariantSpec{} + } +} From 7ddc994c399b3e4ba3e03fcc9191a40538d275d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:31:48 +0200 Subject: [PATCH 03/22] feat(scaffold): render wrapper packages from embedded templates --- internal/scaffold/render.go | 66 +++++ internal/scaffold/render_test.go | 95 +++++++ internal/scaffold/templates/builder.go.tmpl | 264 ++++++++++++++++++ .../scaffold/templates/builder_test.go.tmpl | 175 ++++++++++++ internal/scaffold/templates/mutator.go.tmpl | 116 ++++++++ internal/scaffold/templates/resource.go.tmpl | 179 ++++++++++++ .../golden/integration/builder.go.golden | 228 +++++++++++++++ .../golden/integration/builder_test.go.golden | 146 ++++++++++ .../golden/integration/mutator.go.golden | 116 ++++++++ .../golden/integration/resource.go.golden | 155 ++++++++++ .../static-cluster-scoped/builder.go.golden | 112 ++++++++ .../builder_test.go.golden | 146 ++++++++++ .../static-cluster-scoped/mutator.go.golden | 116 ++++++++ .../static-cluster-scoped/resource.go.golden | 111 ++++++++ .../testdata/golden/static/builder.go.golden | 110 ++++++++ .../golden/static/builder_test.go.golden | 146 ++++++++++ .../testdata/golden/static/mutator.go.golden | 116 ++++++++ .../testdata/golden/static/resource.go.golden | 111 ++++++++ .../testdata/golden/task/builder.go.golden | 202 ++++++++++++++ .../golden/task/builder_test.go.golden | 146 ++++++++++ .../testdata/golden/task/mutator.go.golden | 116 ++++++++ .../testdata/golden/task/resource.go.golden | 146 ++++++++++ .../golden/workload/builder.go.golden | 228 +++++++++++++++ .../golden/workload/builder_test.go.golden | 146 ++++++++++ .../golden/workload/mutator.go.golden | 116 ++++++++ .../golden/workload/resource.go.golden | 155 ++++++++++ 26 files changed, 3763 insertions(+) create mode 100644 internal/scaffold/render.go create mode 100644 internal/scaffold/render_test.go create mode 100644 internal/scaffold/templates/builder.go.tmpl create mode 100644 internal/scaffold/templates/builder_test.go.tmpl create mode 100644 internal/scaffold/templates/mutator.go.tmpl create mode 100644 internal/scaffold/templates/resource.go.tmpl create mode 100644 internal/scaffold/testdata/golden/integration/builder.go.golden create mode 100644 internal/scaffold/testdata/golden/integration/builder_test.go.golden create mode 100644 internal/scaffold/testdata/golden/integration/mutator.go.golden create mode 100644 internal/scaffold/testdata/golden/integration/resource.go.golden create mode 100644 internal/scaffold/testdata/golden/static-cluster-scoped/builder.go.golden create mode 100644 internal/scaffold/testdata/golden/static-cluster-scoped/builder_test.go.golden create mode 100644 internal/scaffold/testdata/golden/static-cluster-scoped/mutator.go.golden create mode 100644 internal/scaffold/testdata/golden/static-cluster-scoped/resource.go.golden create mode 100644 internal/scaffold/testdata/golden/static/builder.go.golden create mode 100644 internal/scaffold/testdata/golden/static/builder_test.go.golden create mode 100644 internal/scaffold/testdata/golden/static/mutator.go.golden create mode 100644 internal/scaffold/testdata/golden/static/resource.go.golden create mode 100644 internal/scaffold/testdata/golden/task/builder.go.golden create mode 100644 internal/scaffold/testdata/golden/task/builder_test.go.golden create mode 100644 internal/scaffold/testdata/golden/task/mutator.go.golden create mode 100644 internal/scaffold/testdata/golden/task/resource.go.golden create mode 100644 internal/scaffold/testdata/golden/workload/builder.go.golden create mode 100644 internal/scaffold/testdata/golden/workload/builder_test.go.golden create mode 100644 internal/scaffold/testdata/golden/workload/mutator.go.golden create mode 100644 internal/scaffold/testdata/golden/workload/resource.go.golden 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..a1033548 --- /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}} "{{.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 readiness 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 stops your workload, +// 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("{{.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..487fcee0 --- /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}} "{{.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, "{{.APIVersion}}/{{.Kind}}/test-object", res.Identity()) +{{- else}} + assert.Equal(t, "{{.APIVersion}}/{{.Kind}}/test-ns/test-object", 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..b9fd16b8 --- /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}} "{{.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..62d38a08 --- /dev/null +++ b/internal/scaffold/templates/resource.go.tmpl @@ -0,0 +1,179 @@ +package {{.Package}} + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/generic" + {{.ImportAlias}} "{{.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. +{{- if eq .Variant "workload"}} +// - concepts.Alive: for health and readiness tracking. +// - concepts.Graceful: for health reporting once the grace period expires. +{{- end}} +{{- if eq .Variant "task"}} +// - concepts.Completable: for run-to-completion tracking. +{{- end}} +{{- if eq .Variant "integration"}} +// - concepts.Operational: for external-dependency readiness tracking. +// - concepts.Graceful: for health reporting once the grace period expires. +{{- 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..9c647778 --- /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 readiness 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 stops your workload, +// 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..b132dd08 --- /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 readiness 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 stops your workload, +// 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..3e92d934 --- /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 readiness 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 stops your workload, +// 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) From e6caa41c502d56c0eaf21ffd4090e98b9bcfaffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:40:48 +0200 Subject: [PATCH 04/22] feat(scaffold): write generated wrapper packages to disk --- internal/scaffold/generate.go | 71 ++++++++++++++++++++++++++ internal/scaffold/generate_test.go | 80 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 internal/scaffold/generate.go create mode 100644 internal/scaffold/generate_test.go diff --git a/internal/scaffold/generate.go b/internal/scaffold/generate.go new file mode 100644 index 00000000..0c4440e1 --- /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, 0o750); 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], 0o600); 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") +} From 40b8cb9d7f100cfff71d83f2885354cd8ff1d401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:46:36 +0200 Subject: [PATCH 05/22] feat(cli): add ocf scaffold wrapper command --- cmd/ocf/cli_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++ cmd/ocf/scaffold.go | 104 ++++++++++++++++++++++++++++++++++- 2 files changed, 232 insertions(+), 2 deletions(-) diff --git a/cmd/ocf/cli_test.go b/cmd/ocf/cli_test.go index 3fd454d2..219379c9 100644 --- a/cmd/ocf/cli_test.go +++ b/cmd/ocf/cli_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "os" + "path/filepath" "runtime/debug" "testing" @@ -41,6 +43,134 @@ func TestVersionCommandPrintsVersion(t *testing.T) { 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") +} + +func TestScaffoldWrapperDefaultsOutToPackageDirectory(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + _, 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")) +} + +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() diff --git a/cmd/ocf/scaffold.go b/cmd/ocf/scaffold.go index 2ef16b2e..20f6a610 100644 --- a/cmd/ocf/scaffold.go +++ b/cmd/ocf/scaffold.go @@ -1,12 +1,112 @@ package main -import "github.com/spf13/cobra" +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 { - return &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, + SilenceUsage: true, + 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() + + if _, err := fmt.Fprintf(out, "Generated %s wrapper package %q in %s:\n", data.Variant, data.Package, dir); 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", filepath.ToSlash(dir), + ); 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 } From c66d4877c586cc800c173e3b6145634a7c76d75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:52:39 +0200 Subject: [PATCH 06/22] fix(cli): make scaffold wrapper's go test hint copy-pasteable for absolute --out --- cmd/ocf/cli_test.go | 10 +++++++++- cmd/ocf/scaffold.go | 19 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/cmd/ocf/cli_test.go b/cmd/ocf/cli_test.go index 219379c9..37ed5025 100644 --- a/cmd/ocf/cli_test.go +++ b/cmd/ocf/cli_test.go @@ -64,13 +64,17 @@ func TestScaffoldWrapperGeneratesPackage(t *testing.T) { 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) - _, err := runCommand(t, + out, err := runCommand(t, "scaffold", "wrapper", "--type", "k8s.io/api/core/v1.ConfigMap", "--variant", "static", @@ -78,6 +82,10 @@ func TestScaffoldWrapperDefaultsOutToPackageDirectory(t *testing.T) { ) 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) { diff --git a/cmd/ocf/scaffold.go b/cmd/ocf/scaffold.go index 20f6a610..e639f5a6 100644 --- a/cmd/ocf/scaffold.go +++ b/cmd/ocf/scaffold.go @@ -84,8 +84,9 @@ func newScaffoldWrapperCommand() *cobra.Command { // 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 := testDirDisplay(dir) - if _, err := fmt.Fprintf(out, "Generated %s wrapper package %q in %s:\n", data.Variant, data.Package, dir); err != nil { + 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 { @@ -101,7 +102,7 @@ func printSummary(cmd *cobra.Command, data scaffold.TemplateData, dir string, wr return err } if _, err := fmt.Fprintf( - out, " 2. Run go test ./%s/... to verify the generated package.\n", filepath.ToSlash(dir), + out, " 2. Run go test %s/... to verify the generated package.\n", display, ); err != nil { return err } @@ -110,3 +111,17 @@ func printSummary(cmd *cobra.Command, data scaffold.TemplateData, dir string, wr ) return err } + +// testDirDisplay formats dir 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 testDirDisplay(dir string) string { + display := filepath.ToSlash(dir) + if filepath.IsAbs(dir) { + return display + } + if strings.HasPrefix(display, "./") || strings.HasPrefix(display, "../") { + return display + } + return "./" + display +} From 5bf22ae74fe22225609704168ec122a4a6700ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:57:08 +0200 Subject: [PATCH 07/22] test(scaffold): gate templates on scaffolded packages compiling and passing Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 5 +- Makefile | 6 +- internal/scaffold/gate_test.go | 109 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 internal/scaffold/gate_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c32d477..6fb19033 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/Makefile b/Makefile index 0aa8ea5e..9b0b6921 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 @@ -125,6 +125,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/internal/scaffold/gate_test.go b/internal/scaffold/gate_test.go new file mode 100644 index 00000000..16682e6c --- /dev/null +++ b/internal/scaffold/gate_test.go @@ -0,0 +1,109 @@ +//go:build scaffold + +package scaffold_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/sourcehawk/operator-component-framework/internal/scaffold" + "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, + }, + } +} + +// TestScaffoldedWrappersCompileAndPass generates every variant into a temporary +// module that replaces the framework with this checkout, then runs the generated +// tests inside it. +func TestScaffoldedWrappersCompileAndPass(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + + moduleDir := t.TempDir() + writeGateModule(t, repoRoot, moduleDir) + + for _, opts := range gateCases() { + data, err := opts.Resolve() + require.NoError(t, err) + + _, err = scaffold.Generate(data, filepath.Join(moduleDir, data.Package), false) + require.NoError(t, err, "generate %s", data.Package) + } + + out, err := runGo(t, moduleDir, "test", "./...") + require.NoError(t, err, "go test in scaffolded module failed:\n%s", out) +} + +// 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 ocfscaffoldgate\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.Command("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 +} From ce45a2d17b7465d683f8058b495980e08ee07565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:06:26 +0200 Subject: [PATCH 08/22] fix(scaffold): gate positively asserts tests ran, not just exit code go test exits 0 for a package with no test files and for a test file with zero Test functions, so the gate could pass vacuously if a template regression gutted or omitted builder_test.go. Switch to `go test -json` and require at least one passing test per generated package, and assert Generate's returned file list matches the expected file set instead of discarding it. Co-Authored-By: Claude Opus 5 (1M context) --- internal/scaffold/gate_test.go | 95 ++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/internal/scaffold/gate_test.go b/internal/scaffold/gate_test.go index 16682e6c..5afe2527 100644 --- a/internal/scaffold/gate_test.go +++ b/internal/scaffold/gate_test.go @@ -3,6 +3,8 @@ package scaffold_test import ( + "bytes" + "encoding/json" "os" "os/exec" "path/filepath" @@ -10,6 +12,7 @@ import ( "testing" "github.com/sourcehawk/operator-component-framework/internal/scaffold" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,9 +33,22 @@ func gateCases() []scaffold.Options { } } +// 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) @@ -40,16 +56,55 @@ func TestScaffoldedWrappersCompileAndPass(t *testing.T) { 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) - _, err = scaffold.Generate(data, filepath.Join(moduleDir, data.Package), false) + 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) } - out, err := runGo(t, moduleDir, "test", "./...") + 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 @@ -70,7 +125,7 @@ func writeGateModule(t *testing.T, repoRoot, moduleDir string) { requires.WriteString("\t" + path + " " + version + "\n") } - goMod := "module ocfscaffoldgate\n\n" + + goMod := "module " + gateModuleName + "\n\n" + "go " + goVersion + "\n\n" + "require (\n" + "\tgithub.com/sourcehawk/operator-component-framework v0.0.0\n" + @@ -107,3 +162,37 @@ func mustRunGo(t *testing.T, dir string, args ...string) string { 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() + + cmd := exec.Command("go", "test", "-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 + if decodeErr := decoder.Decode(&ev); decodeErr != nil { + break + } + events = append(events, ev) + } + + return events, rawStdout + stderr.String(), runErr +} From 7e6ab7b80dcc3a38f77c8c7b60f7cdcb37ce97f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:17:38 +0200 Subject: [PATCH 09/22] docs: document the ocf scaffolding CLI --- .ai/base.md | 3 + .github/copilot-instructions.md | 3 + README.md | 18 +++++ docs/cli.md | 132 ++++++++++++++++++++++++++++++++ docs/custom-resource.md | 6 ++ mkdocs.yml | 1 + 6 files changed, 163 insertions(+) create mode 100644 docs/cli.md diff --git a/.ai/base.md b/.ai/base.md index 3a451665..95d7fa14 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/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4e837b00..9b465054 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/README.md b/README.md index 715bb07d..95bbfebc 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/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..7c06d0bd --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,132 @@ +# 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. Pass `--group ""` for core API group types | +| `--version` | no | last import-path segment when it looks like an API version | API version | +| `--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 | + +### 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 + +`--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. + +`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/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: From 93329da68d312b2561ea0d757aef0f4cfa87fc4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:23:12 +0200 Subject: [PATCH 10/22] docs: document version and alias derivation failure modes in cli.md --- docs/cli.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7c06d0bd..a497742a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,18 +18,18 @@ package came from. `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. Pass `--group ""` for core API group types | -| `--version` | no | last import-path segment when it looks like an API version | API version | -| `--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 | +| 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. Pass `--group ""` for core API group types | +| `--version` | no | last import-path segment when it looks like an API version; required otherwise | API version | +| `--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 | ### Choosing a variant @@ -94,7 +94,13 @@ start replacing the scaffolded defaults. 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. +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 looks like an API version: a lowercase `v` +followed by digits, optionally followed by `alpha` or `beta` and more digits, for example `v1`, `v2beta1`, or +`v1alpha3`. 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. From 9045f525ec3507dbafec3c5f1c9c8ebce6f2d035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:40:17 +0200 Subject: [PATCH 11/22] fix(scaffold): validate --group and --version and quote generated identities --version was only validated on the derivation path, and --group was never validated at all. Both land inside a Go string literal in the generated builder.go and builder_test.go, so "--version 1.0" produced a silently wrong identity that still compiled, and a --group containing a double quote could close the literal and inject an arbitrary expression into the generated file. Resolve now rejects a --group that is not a DNS subdomain the way Kubernetes defines API groups, keeping "" valid for the core API group, and checks an explicit --version against the same pattern the derivation path uses. The templates additionally emit the identity format string and the identity assertions through printf "%q", so no input can break out of the literal even if a future validation gap appears. Rendered output for well-formed input is unchanged, so no golden moved. Co-Authored-By: Claude Opus 5 (1M context) --- docs/cli.md | 48 +++++++---- internal/scaffold/options.go | 11 ++- internal/scaffold/options_test.go | 79 +++++++++++++++++++ internal/scaffold/templates/builder.go.tmpl | 2 +- .../scaffold/templates/builder_test.go.tmpl | 4 +- 5 files changed, 123 insertions(+), 21 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index a497742a..c2611ae4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,18 +18,34 @@ package came from. `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. Pass `--group ""` for core API group types | -| `--version` | no | last import-path segment when it looks like an API version; required otherwise | API version | -| `--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 | +| 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 @@ -97,10 +113,10 @@ above, `github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1` derives `c 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 looks like an API version: a lowercase `v` -followed by digits, optionally followed by `alpha` or `beta` and more digits, for example `v1`, `v2beta1`, or -`v1alpha3`. 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. +`--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. diff --git a/internal/scaffold/options.go b/internal/scaffold/options.go index 2b73baf3..a7fd9ad0 100644 --- a/internal/scaffold/options.go +++ b/internal/scaffold/options.go @@ -9,6 +9,7 @@ import ( 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])?)*$`) 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_]*$`) @@ -55,11 +56,15 @@ func (o Options) Resolve() (TemplateData, error) { if !o.GroupSet { return TemplateData{}, fmt.Errorf(`--group is required (pass --group "" for core API group types)`) } - - lastSegment := lastPathSegment(importPath) + 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", @@ -67,6 +72,8 @@ func (o Options) Resolve() (TemplateData, error) { ) } version = lastSegment + } else if !apiVersionPattern.MatchString(version) { + return TemplateData{}, fmt.Errorf("--version %q is not a valid API version", version) } kind := o.Kind diff --git a/internal/scaffold/options_test.go b/internal/scaffold/options_test.go index 580427d0..a0c5742f 100644 --- a/internal/scaffold/options_test.go +++ b/internal/scaffold/options_test.go @@ -117,6 +117,55 @@ func TestResolveDerivations(t *testing.T) { } } +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() @@ -165,6 +214,26 @@ func TestResolveValidationErrors(t *testing.T) { 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" }, @@ -233,6 +302,7 @@ func TestVariantSpecs(t *testing.T) { 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) @@ -241,16 +311,25 @@ func TestVariantSpecs(t *testing.T) { 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/templates/builder.go.tmpl b/internal/scaffold/templates/builder.go.tmpl index a1033548..abecc58a 100644 --- a/internal/scaffold/templates/builder.go.tmpl +++ b/internal/scaffold/templates/builder.go.tmpl @@ -105,7 +105,7 @@ type Builder struct { {{- end}} func NewBuilder(obj {{.PointerType}}) *Builder { identityFunc := func(o {{.PointerType}}) string { - return fmt.Sprintf("{{.IdentityFormat}}", {{.IdentityArgs}}) + return fmt.Sprintf({{printf "%q" .IdentityFormat}}, {{.IdentityArgs}}) } base := generic.{{$spec.GenericConstructor}}[{{.PointerType}}, *Mutator]( diff --git a/internal/scaffold/templates/builder_test.go.tmpl b/internal/scaffold/templates/builder_test.go.tmpl index 487fcee0..69e25b6c 100644 --- a/internal/scaffold/templates/builder_test.go.tmpl +++ b/internal/scaffold/templates/builder_test.go.tmpl @@ -91,9 +91,9 @@ func TestBuilderBuildValidation(t *testing.T) { require.NoError(t, err) require.NotNil(t, res) {{- if .ClusterScoped}} - assert.Equal(t, "{{.APIVersion}}/{{.Kind}}/test-object", res.Identity()) + assert.Equal(t, {{printf "%q" (printf "%s/%s/test-object" .APIVersion .Kind)}}, res.Identity()) {{- else}} - assert.Equal(t, "{{.APIVersion}}/{{.Kind}}/test-ns/test-object", res.Identity()) + assert.Equal(t, {{printf "%q" (printf "%s/%s/test-ns/test-object" .APIVersion .Kind)}}, res.Identity()) {{- end}} }) } From 75c7bf7c06e6e7f211aa9a9a327cb1be2ca36dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:40:29 +0200 Subject: [PATCH 12/22] chore(lint): lint the scaffold-tagged gate file and bound its go invocations .golangci.yml set no run.build-tags, so internal/scaffold/gate_test.go was invisible to make lint and two noctx violations went unreported. Adding the scaffold build tag to the run section keeps the file linted from now on. The gate also shelled out to the go tool with no context, so a hung go tool in CI would hang until the job timeout instead of failing with a diagnostic. runGo and runGoTestJSON now use exec.CommandContext with the test's context. Co-Authored-By: Claude Opus 5 (1M context) --- .golangci.yml | 2 ++ internal/scaffold/gate_test.go | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index c8d26213..854eb664 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/internal/scaffold/gate_test.go b/internal/scaffold/gate_test.go index 5afe2527..eb25d9d6 100644 --- a/internal/scaffold/gate_test.go +++ b/internal/scaffold/gate_test.go @@ -144,7 +144,7 @@ func writeGateModule(t *testing.T, repoRoot, moduleDir string) { func runGo(t *testing.T, dir string, args ...string) (string, error) { t.Helper() - cmd := exec.Command("go", args...) + cmd := exec.CommandContext(t.Context(), "go", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") @@ -173,7 +173,7 @@ func mustRunGo(t *testing.T, dir string, args ...string) string { func runGoTestJSON(t *testing.T, dir string) ([]testEvent, string, error) { t.Helper() - cmd := exec.Command("go", "test", "-json", "./...") + cmd := exec.CommandContext(t.Context(), "go", "test", "-json", "./...") cmd.Dir = dir cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") From c034dbad66fdea7e10505418fc8f9d89f5a0e91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:40:29 +0200 Subject: [PATCH 13/22] refactor(scaffold): drive the Resource interface list from VariantSpec resource.go.tmpl branched on the variant name to build the "It implements the following component interfaces" list, contradicting VariantSpec's own contract that templates read the spec instead of branching on the name. A fifth variant would have needed edits in two places, and forgetting the template would silently produce a Resource whose GoDoc omits its lifecycle interface. The variant-specific bullets now live in VariantSpec.LifecycleInterfaces and the template ranges over them. Rendered output is byte-identical for every golden case. Co-Authored-By: Claude Opus 5 (1M context) --- internal/scaffold/templates/resource.go.tmpl | 12 ++---------- internal/scaffold/variant.go | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/scaffold/templates/resource.go.tmpl b/internal/scaffold/templates/resource.go.tmpl index 62d38a08..f583cd75 100644 --- a/internal/scaffold/templates/resource.go.tmpl +++ b/internal/scaffold/templates/resource.go.tmpl @@ -13,16 +13,8 @@ import ( // // It implements the following component interfaces: // - component.Resource: for basic identity and mutation behaviour. -{{- if eq .Variant "workload"}} -// - concepts.Alive: for health and readiness tracking. -// - concepts.Graceful: for health reporting once the grace period expires. -{{- end}} -{{- if eq .Variant "task"}} -// - concepts.Completable: for run-to-completion tracking. -{{- end}} -{{- if eq .Variant "integration"}} -// - concepts.Operational: for external-dependency readiness tracking. -// - concepts.Graceful: for health reporting once the grace period expires. +{{- range $spec.LifecycleInterfaces}} +// - {{.}} {{- end}} {{- if $spec.HasSuspension}} // - concepts.Suspendable: for temporary deactivation. diff --git a/internal/scaffold/variant.go b/internal/scaffold/variant.go index b7f9920f..55238a0b 100644 --- a/internal/scaffold/variant.go +++ b/internal/scaffold/variant.go @@ -48,6 +48,11 @@ type VariantSpec struct { 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 @@ -75,6 +80,10 @@ func (v Variant) Spec() VariantSpec { 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{ @@ -90,6 +99,9 @@ func (v Variant) Spec() VariantSpec { StatusValue: "Completed", StatusNoun: "completed", HasSuspension: true, + LifecycleInterfaces: []string{ + "concepts.Completable: for run-to-completion tracking.", + }, } case VariantIntegration: return VariantSpec{ @@ -106,6 +118,10 @@ func (v Variant) Spec() VariantSpec { 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{} From 747f90200c248616b22546bcb70a9f216f6e41b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:40:29 +0200 Subject: [PATCH 14/22] fix(scaffold): write generated sources with normal file permissions Generate created the output directory 0750 and wrote the four files 0600. That is the wrong default for source a user will edit, commit and share; the repo's own golden writer already uses 0644. Use 0755 and 0644. Co-Authored-By: Claude Opus 5 (1M context) --- internal/scaffold/generate.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/scaffold/generate.go b/internal/scaffold/generate.go index 0c4440e1..e507b1e8 100644 --- a/internal/scaffold/generate.go +++ b/internal/scaffold/generate.go @@ -24,14 +24,14 @@ func Generate(data TemplateData, outDir string, force bool) ([]string, error) { return nil, err } - if err := os.MkdirAll(outDir, 0o750); err != nil { + 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], 0o600); err != nil { + if err := os.WriteFile(path, files[name], 0o644); err != nil { return nil, fmt.Errorf("write %q: %w", path, err) } written = append(written, path) From a0bfacf38e5f52904ccdb526844ab7536a271b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:40:29 +0200 Subject: [PATCH 15/22] refactor(cli): rename displayDir and drop the wrapper's dead SilenceUsage testDirDisplay formats the summary header line as well as the go test hint, so the name was narrower than the job; it is now displayDir, with a doc comment that says so. SilenceUsage on the wrapper subcommand was dead: cobra consults only the executed command and the root, and the root already sets it. Error paths still print a bare error with no usage dump. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/ocf/scaffold.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/ocf/scaffold.go b/cmd/ocf/scaffold.go index e639f5a6..35d340b4 100644 --- a/cmd/ocf/scaffold.go +++ b/cmd/ocf/scaffold.go @@ -41,8 +41,7 @@ func newScaffoldWrapperCommand() *cobra.Command { 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, - SilenceUsage: true, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { opts.GroupSet = cmd.Flags().Changed("group") @@ -84,7 +83,7 @@ func newScaffoldWrapperCommand() *cobra.Command { // 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 := testDirDisplay(dir) + 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 @@ -112,10 +111,11 @@ func printSummary(cmd *cobra.Command, data scaffold.TemplateData, dir string, wr return err } -// testDirDisplay formats dir 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 testDirDisplay(dir string) string { +// 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 From 354c364bd51a65fcc7e52c1e7d540316db3a0e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:50:39 +0200 Subject: [PATCH 16/22] fix(scaffold): validate the --type import path and quote generated imports --type's import path was never validated. splitType checked only the type name and deriveAlias only sanitized the alias, so anything before the last dot landed verbatim inside the import literal of all four generated files. A --type whose path carried a double quote and a newline closed the literal and injected an extra import, for example a blank "os" import, into the generated package. The result was syntactically valid, so go/format accepted it and the package compiled and ran the injected package's init. splitType now rejects a path that is not shaped like a Go import path: one or more slash-separated elements, each non-empty, built only from the ASCII characters the module system permits in a path element, and neither starting nor ending in a dot. The templates additionally emit the import literal through printf "%q", the same boundary quoting the identity format string already uses, so no input can break out of the literal even if a future validation gap appears. Rendered output for well-formed input is unchanged, so no golden moved. Co-Authored-By: Claude Opus 5 (1M context) --- docs/cli.md | 6 +++ internal/scaffold/options.go | 40 ++++++++++++++++--- internal/scaffold/options_test.go | 27 +++++++++++++ internal/scaffold/templates/builder.go.tmpl | 2 +- .../scaffold/templates/builder_test.go.tmpl | 2 +- internal/scaffold/templates/mutator.go.tmpl | 2 +- internal/scaffold/templates/resource.go.tmpl | 2 +- 7 files changed, 71 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c2611ae4..eebbbf7e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -106,6 +106,12 @@ 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 diff --git a/internal/scaffold/options.go b/internal/scaffold/options.go index a7fd9ad0..31a0ab93 100644 --- a/internal/scaffold/options.go +++ b/internal/scaffold/options.go @@ -8,12 +8,13 @@ import ( ) 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])?)*$`) - 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]`) + 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 @@ -142,6 +143,9 @@ func splitType(value string) (importPath, typeName string, err error) { 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) } @@ -149,6 +153,30 @@ func splitType(value string) (importPath, typeName string, err error) { 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 == "" { diff --git a/internal/scaffold/options_test.go b/internal/scaffold/options_test.go index a0c5742f..4809d91a 100644 --- a/internal/scaffold/options_test.go +++ b/internal/scaffold/options_test.go @@ -74,6 +74,17 @@ func TestResolveDerivations(t *testing.T) { 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) { @@ -194,6 +205,22 @@ func TestResolveValidationErrors(t *testing.T) { 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 = "" }, diff --git a/internal/scaffold/templates/builder.go.tmpl b/internal/scaffold/templates/builder.go.tmpl index abecc58a..60b3ac99 100644 --- a/internal/scaffold/templates/builder.go.tmpl +++ b/internal/scaffold/templates/builder.go.tmpl @@ -6,7 +6,7 @@ import ( "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/feature" "github.com/sourcehawk/operator-component-framework/pkg/generic" - {{.ImportAlias}} "{{.ImportPath}}" + {{.ImportAlias}} {{printf "%q" .ImportPath}} ) {{- $spec := .Spec}} {{- if $spec.HasStatus}} diff --git a/internal/scaffold/templates/builder_test.go.tmpl b/internal/scaffold/templates/builder_test.go.tmpl index 69e25b6c..ed6e1bdb 100644 --- a/internal/scaffold/templates/builder_test.go.tmpl +++ b/internal/scaffold/templates/builder_test.go.tmpl @@ -7,7 +7,7 @@ import ( "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - {{.ImportAlias}} "{{.ImportPath}}" + {{.ImportAlias}} {{printf "%q" .ImportPath}} metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/internal/scaffold/templates/mutator.go.tmpl b/internal/scaffold/templates/mutator.go.tmpl index b9fd16b8..53590dac 100644 --- a/internal/scaffold/templates/mutator.go.tmpl +++ b/internal/scaffold/templates/mutator.go.tmpl @@ -4,7 +4,7 @@ package {{.Package}} import ( "github.com/sourcehawk/operator-component-framework/pkg/feature" "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - {{.ImportAlias}} "{{.ImportPath}}" + {{.ImportAlias}} {{printf "%q" .ImportPath}} ) // Mutation defines a mutation that is applied to the {{.Kind}} Mutator diff --git a/internal/scaffold/templates/resource.go.tmpl b/internal/scaffold/templates/resource.go.tmpl index f583cd75..b8e9177e 100644 --- a/internal/scaffold/templates/resource.go.tmpl +++ b/internal/scaffold/templates/resource.go.tmpl @@ -3,7 +3,7 @@ package {{.Package}} import ( "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/generic" - {{.ImportAlias}} "{{.ImportPath}}" + {{.ImportAlias}} {{printf "%q" .ImportPath}} "sigs.k8s.io/controller-runtime/pkg/client" ) {{- $spec := .Spec}} From 9cf2713c03cdb745afc46917085bf1a3f599bb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:57:39 +0200 Subject: [PATCH 17/22] test(scaffold): fail the gate on corrupted go test -json output The decode loop broke on any decoder error, so a truncated or corrupted event stream silently dropped the remaining events and surfaced later as a package that appeared to have run no tests. Only io.EOF now ends the loop; any other decode error is joined with the run error and returned, so the gate fails with the decode error and the captured output. --- internal/scaffold/gate_test.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/scaffold/gate_test.go b/internal/scaffold/gate_test.go index eb25d9d6..7384c6b7 100644 --- a/internal/scaffold/gate_test.go +++ b/internal/scaffold/gate_test.go @@ -5,6 +5,9 @@ package scaffold_test import ( "bytes" "encoding/json" + "errors" + "fmt" + "io" "os" "os/exec" "path/filepath" @@ -188,9 +191,18 @@ func runGoTestJSON(t *testing.T, dir string) ([]testEvent, string, error) { decoder := json.NewDecoder(strings.NewReader(rawStdout)) for { var ev testEvent - if decodeErr := decoder.Decode(&ev); decodeErr != nil { + 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) } From 6cfd4f08e2a6c70384b5e74c2539578012f4029d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:03:39 +0200 Subject: [PATCH 18/22] fix(scaffold): variant-accurate handler GoDoc and cache-free gate runs The scaffolded status handler told every kind to inspect the fields it "reports readiness through", which is wrong for task and integration wrappers that report completion and operational state. It now names the variant's own state. The suspension handler said to replace it with the change that "stops your workload", which misdescribes an integration kind; it now says to take the object out of service. The gate's inner go test run adds -count=1 so it executes the generated tests on every run instead of replaying a cached result. --- internal/scaffold/gate_test.go | 4 +++- internal/scaffold/templates/builder.go.tmpl | 6 +++--- .../scaffold/testdata/golden/integration/builder.go.golden | 6 +++--- internal/scaffold/testdata/golden/task/builder.go.golden | 6 +++--- .../scaffold/testdata/golden/workload/builder.go.golden | 6 +++--- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/internal/scaffold/gate_test.go b/internal/scaffold/gate_test.go index 7384c6b7..a41a6f02 100644 --- a/internal/scaffold/gate_test.go +++ b/internal/scaffold/gate_test.go @@ -176,7 +176,9 @@ func mustRunGo(t *testing.T, dir string, args ...string) string { func runGoTestJSON(t *testing.T, dir string) ([]testEvent, string, error) { t.Helper() - cmd := exec.CommandContext(t.Context(), "go", "test", "-json", "./...") + // -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") diff --git a/internal/scaffold/templates/builder.go.tmpl b/internal/scaffold/templates/builder.go.tmpl index 60b3ac99..0988c66a 100644 --- a/internal/scaffold/templates/builder.go.tmpl +++ b/internal/scaffold/templates/builder.go.tmpl @@ -15,7 +15,7 @@ import ( // // 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 readiness through. +// your {{.Kind}} reports its {{$spec.StatusNoun}} state through. func {{$spec.StatusHandler}}( _ concepts.ConvergingOperation, _ {{.PointerType}}, ) ({{$spec.StatusResult}}, error) { @@ -47,8 +47,8 @@ func DefaultGraceStatusHandler(_ {{.PointerType}}) (concepts.GraceStatusWithReas // 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 stops your workload, -// for example scaling to zero or setting a suspended field. +// 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 } diff --git a/internal/scaffold/testdata/golden/integration/builder.go.golden b/internal/scaffold/testdata/golden/integration/builder.go.golden index 9c647778..a68bb57a 100644 --- a/internal/scaffold/testdata/golden/integration/builder.go.golden +++ b/internal/scaffold/testdata/golden/integration/builder.go.golden @@ -13,7 +13,7 @@ import ( // // 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 readiness through. +// your Ingress reports its operational state through. func DefaultOperationalStatusHandler( _ concepts.ConvergingOperation, _ *networkingv1.Ingress, ) (concepts.OperationalStatusWithReason, error) { @@ -41,8 +41,8 @@ func DefaultGraceStatusHandler(_ *networkingv1.Ingress) (concepts.GraceStatusWit // 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 stops your workload, -// for example scaling to zero or setting a suspended field. +// 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 } diff --git a/internal/scaffold/testdata/golden/task/builder.go.golden b/internal/scaffold/testdata/golden/task/builder.go.golden index b132dd08..c9ab9313 100644 --- a/internal/scaffold/testdata/golden/task/builder.go.golden +++ b/internal/scaffold/testdata/golden/task/builder.go.golden @@ -13,7 +13,7 @@ import ( // // 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 readiness through. +// your Job reports its completed state through. func DefaultConvergingStatusHandler( _ concepts.ConvergingOperation, _ *batchv1.Job, ) (concepts.CompletionStatusWithReason, error) { @@ -27,8 +27,8 @@ func DefaultConvergingStatusHandler( // 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 stops your workload, -// for example scaling to zero or setting a suspended field. +// 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 } diff --git a/internal/scaffold/testdata/golden/workload/builder.go.golden b/internal/scaffold/testdata/golden/workload/builder.go.golden index 3e92d934..c9a44c61 100644 --- a/internal/scaffold/testdata/golden/workload/builder.go.golden +++ b/internal/scaffold/testdata/golden/workload/builder.go.golden @@ -13,7 +13,7 @@ import ( // // 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 readiness through. +// your Deployment reports its converged state through. func DefaultConvergingStatusHandler( _ concepts.ConvergingOperation, _ *appsv1.Deployment, ) (concepts.AliveStatusWithReason, error) { @@ -41,8 +41,8 @@ func DefaultGraceStatusHandler(_ *appsv1.Deployment) (concepts.GraceStatusWithRe // 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 stops your workload, -// for example scaling to zero or setting a suspended field. +// 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 } From 4ee6107c5ce78e990422a24fe2c1e6b185b2e512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:30:54 +0200 Subject: [PATCH 19/22] chore: mark generated doc copies as generated files The plugin skill references are verbatim copies of docs/ produced by make sync-plugin, and the copilot instruction files are produced by make ai-instructions. Marking them linguist-generated collapses them in pull request diffs and keeps them out of language statistics, so a docs change reviews as the source file rather than as the source plus its copies. --- .gitattributes | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .gitattributes 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 From bb31b239dd8cb470628475fac51935b6281df455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:30:54 +0200 Subject: [PATCH 20/22] docs(plugin): point the wrapper skill and command at the ocf CLI The custom-resource-wrappers skill and the new-wrapper command walked through writing the wrapper package by hand, which is now the fallback rather than the first move. Both lead with ocf scaffold wrapper and keep the eight steps as the reference for what the generated code means and for extending a wrapper that already exists. --- plugin/commands/new-wrapper.md | 17 ++++++++++++----- .../skills/custom-resource-wrappers/SKILL.md | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) 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. From d4cdb50f2e37574f821e5a87d2b82121b9d4bfd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:49:17 +0200 Subject: [PATCH 21/22] refactor(scaffold): extract the repeated ConvergingStatus method name golangci-lint 2.12.2, which CI now pins through .tool-versions, flags the literal repeated across the three status-bearing variants. It is one value by definition, so it becomes a named constant. --- internal/scaffold/variant.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/scaffold/variant.go b/internal/scaffold/variant.go index 55238a0b..6db66886 100644 --- a/internal/scaffold/variant.go +++ b/internal/scaffold/variant.go @@ -19,6 +19,10 @@ const ( // 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 { @@ -32,7 +36,8 @@ type VariantSpec struct { HasStatus bool // StatusSetter is the builder method registering the status handler. StatusSetter string - // StatusMethod is the resource method forwarding the status, always "ConvergingStatus". + // StatusMethod is the resource method forwarding the status, always + // convergingStatusMethod. StatusMethod string // StatusResult is the qualified status result type. StatusResult string @@ -72,7 +77,7 @@ func (v Variant) Spec() VariantSpec { GenericResource: "WorkloadResource", HasStatus: true, StatusSetter: "WithCustomConvergeStatus", - StatusMethod: "ConvergingStatus", + StatusMethod: convergingStatusMethod, StatusResult: "concepts.AliveStatusWithReason", StatusHandler: "DefaultConvergingStatusHandler", StatusConstant: "concepts.AliveConvergingStatusHealthy", @@ -92,7 +97,7 @@ func (v Variant) Spec() VariantSpec { GenericResource: "TaskResource", HasStatus: true, StatusSetter: "WithCustomConvergeStatus", - StatusMethod: "ConvergingStatus", + StatusMethod: convergingStatusMethod, StatusResult: "concepts.CompletionStatusWithReason", StatusHandler: "DefaultConvergingStatusHandler", StatusConstant: "concepts.CompletionStatusCompleted", @@ -110,7 +115,7 @@ func (v Variant) Spec() VariantSpec { GenericResource: "IntegrationResource", HasStatus: true, StatusSetter: "WithCustomOperationalStatus", - StatusMethod: "ConvergingStatus", + StatusMethod: convergingStatusMethod, StatusResult: "concepts.OperationalStatusWithReason", StatusHandler: "DefaultOperationalStatusHandler", StatusConstant: "concepts.OperationalStatusOperational", From 6f3f9a45ab311a7a44774af4fa985a6327ecd9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:49:17 +0200 Subject: [PATCH 22/22] fix(component): use reflect.Pointer in the data cell nil check govet under golangci-lint 2.12.2 flags reflect.Ptr, the pre-1.18 alias, in favour of reflect.Pointer. This is the only remaining use in the module and it currently fails lint on main, independently of this branch. --- pkg/component/data.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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