diff --git a/.golangci.yml b/.golangci.yml index 2d59f17..5cc4e57 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -35,3 +35,7 @@ linters: list-mode: strict allow: - $gostd + # Portable runtime flag API — the feature-flag-first delivery + # convention (see AGENTS.md ## Maintenance). Swap the provider for + # flagd / a managed backend in a real service; call sites are stdlib. + - github.com/open-feature/go-sdk diff --git a/AGENTS.md b/AGENTS.md index f3e5d11..dbde580 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,28 @@ broadly compatible as possible. Nothing else hard-codes the version — `copilot `go-version-file: go.mod` and the README points at `go.mod` — so keep it that way (no second copy to drift). +**Feature-flag-first delivery.** Generated services land every new feature +**behind a flag, default-off**, and flip it on only after validation — the +portfolio-wide convention (devantler-tech/monorepo#2059). The scaffold wires the +portable **OpenFeature Go SDK** in `pkg/featureflag`: `NewProvider` (in-memory, +so the example evaluates with no backend — swap for **flagd** or a managed +backend in a real service), `NewClient`, and `Enabled` (default-off on any error +or missing flag). Guard the new path behind `Enabled`, keep the old path as the +default, and **cover both states** with a table-driven test. **Lifecycle is +mandatory:** short-lived *release* flags are **removed after rollout** (flag debt +is the #1 failure mode); long-lived *ops/permission* flags are the exception. +Delete `pkg/featureflag` (like `pkg/example`) when you add your own. The SDK is +the one allowance in `.golangci.yml`'s `depguard` strict allow-list; keep the +rest of the non-test scaffold stdlib-only. + +**Services vs CLIs — reach for the SDK only when you need it.** The OpenFeature +SDK is for **services** that need runtime evaluation (targeting, gradual +rollout, a remote backend). A generated **CLI** should stay dependency-free: +gate an experimental command/flag behind cobra's `Hidden: true` (off `--help`) +plus an `--experimental` opt-in (or a config gate), and only add the SDK where +richer evaluation is genuinely needed. Same default-off, remove-after-rollout +lifecycle either way. + **Validate before any PR (locally):** `golangci-lint fmt` (if configured), `go build ./... && go test ./...`, `golangci-lint run` — local checks for fast feedback (the ruleset-injected `validate-go-project` workflow re-runs build/test/lint/coverage on the PR — see *Validation* above; don't duplicate it into `ci.yaml`). Workflows → `actionlint`. **Task menu** (light; ≤1 high-value item per run): diff --git a/README.md b/README.md index 3671b79..cd7f814 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A minimal, batteries-included Go template for new projects. Skip the boilerplate ## ✨ What's included - **Idiomatic scaffold** — a no-op `main.go` plus the conventional `cmd/`, `internal/`, and `pkg/` layout, ready for your first package. A minimal [`pkg/example`](pkg/example) package with a table-driven test shows the house testing pattern — replace it with your own. +- **Feature-flag-first** — [`pkg/featureflag`](pkg/featureflag) wires the portable [OpenFeature](https://openfeature.dev/) Go SDK so a **service** can land every new feature behind a flag, default-off, and flip it on only after validation (swap the in-memory provider for [flagd](https://flagd.dev/) or a managed backend). A **CLI** can stay dependency-free instead — gate experimental commands behind cobra `Hidden` + an `--experimental` opt-in, reaching for the SDK only when you need richer evaluation. Delete it when you add your own. - **Linting & formatting** — [`golangci-lint`](https://golangci-lint.run/) v2 (formatters + `default: all` linters) in CI, with [MegaLinter](https://megalinter.io/) covering everything else. A [pre-commit](https://pre-commit.com/) hook runs `golangci-lint` formatting (and `mockery` mock generation) locally on commit. - **CI/CD** — a required-checks workflow on pull requests and the merge queue, plus a [GoReleaser](https://goreleaser.com/) release pipeline (`cd.yaml`) triggered on `v*` tags. - **Coverage** — `go test` coverage reported via [GitHub Code Quality](https://docs.github.com/code-security/code-quality). diff --git a/go.mod b/go.mod index a4602b4..f18f1dd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/devantler-tech/go-template go 1.25.10 + +require github.com/open-feature/go-sdk v1.17.2 + +require ( + github.com/go-logr/logr v1.4.3 // indirect + go.uber.org/mock v0.6.0 // indirect +) diff --git a/go.sum b/go.sum index e69de29..880c15f 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/open-feature/go-sdk v1.17.2 h1:pTdeNks/hgnPrlqdgtFwltnIron1oOxqg4FmLlirJlY= +github.com/open-feature/go-sdk v1.17.2/go.mod h1:kTMCquVtck18XdSCI6rBoNFEBLvkOy4Tphu2pV8bq34= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/featureflag/featureflag.go b/pkg/featureflag/featureflag.go new file mode 100644 index 0000000..1ca6c94 --- /dev/null +++ b/pkg/featureflag/featureflag.go @@ -0,0 +1,89 @@ +// Package featureflag is a minimal, replaceable feature-flag scaffold. +// +// It wires the portable OpenFeature Go SDK (github.com/open-feature/go-sdk) as +// the runtime flag API so every new feature can be landed behind a flag, +// default-off, and flipped on only after validation — the feature-flag-first +// delivery convention (see AGENTS.md ## Maintenance). +// +// The template ships the in-memory provider (openfeature/memprovider) so the +// example flag evaluates and its tests pass with no running backend. In a real +// service, swap the provider for flagd +// (github.com/open-feature/go-sdk-contrib/providers/flagd) to read FeatureFlag +// custom resources from the GitOps platform, or a managed backend — call sites +// (Enabled below) are unchanged because they only touch the OpenFeature client. +// +// Lifecycle: short-lived *release* flags are removed after rollout (flag debt +// is the #1 failure mode); long-lived *ops/permission* flags are the exception. +// Delete this package and its example flag when you add your own first feature. +package featureflag + +import ( + "context" + "fmt" + + "github.com/open-feature/go-sdk/openfeature" + "github.com/open-feature/go-sdk/openfeature/memprovider" +) + +// ExampleFlag is a sample boolean flag key, evaluated default-off. +// +// It gates ExampleFeature below purely to demonstrate the both-states pattern; +// replace it with your own feature's key. +const ExampleFlag = "example-feature" + +// NewProvider builds an OpenFeature provider that serves the given boolean flag +// states in-process — the template's default so the example evaluates and its +// tests pass with no running backend. +// +// In a real service, replace this with the flagd provider (or a managed +// backend); call sites are unaffected because they only touch the client. +func NewProvider(flags map[string]bool) memprovider.InMemoryProvider { + memFlags := make(map[string]memprovider.InMemoryFlag, len(flags)) + + for key, on := range flags { + variant := "off" + if on { + variant = "on" + } + + memFlags[key] = memprovider.InMemoryFlag{ + State: memprovider.Enabled, + DefaultVariant: variant, + Variants: map[string]any{"on": true, "off": false}, + } + } + + return memprovider.NewInMemoryProvider(memFlags) +} + +// NewClient registers provider under domain and returns a client bound to it. +// +// A real service calls this once at startup (domain = the service name); the +// returned client is safe to share across goroutines. +func NewClient(domain string, provider openfeature.FeatureProvider) (*openfeature.Client, error) { + err := openfeature.SetNamedProviderAndWait(domain, provider) + if err != nil { + return nil, fmt.Errorf("register feature-flag provider for %q: %w", domain, err) + } + + return openfeature.NewClient(domain), nil +} + +// Enabled reports whether flag is on for client, defaulting to OFF on a missing +// flag or any evaluation error — the default-off rule every new feature follows +// so a flag failure can never silently turn a feature on. +func Enabled(ctx context.Context, client *openfeature.Client, flag string) bool { + return client.Boolean(ctx, flag, false, openfeature.EvaluationContext{}) +} + +// ExampleFeature returns the feature's output when ExampleFlag is enabled and a +// safe default otherwise — the shape a real feature takes: guard the new path +// behind Enabled, keep the old path as the default. Delete this when you add +// your own first flagged feature. +func ExampleFeature(ctx context.Context, client *openfeature.Client) string { + if Enabled(ctx, client, ExampleFlag) { + return "new feature output" + } + + return "default output" +} diff --git a/pkg/featureflag/featureflag_test.go b/pkg/featureflag/featureflag_test.go new file mode 100644 index 0000000..2356a60 --- /dev/null +++ b/pkg/featureflag/featureflag_test.go @@ -0,0 +1,52 @@ +package featureflag_test + +import ( + "context" + "testing" + + "github.com/devantler-tech/go-template/pkg/featureflag" +) + +func TestExampleFeature(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + flags map[string]bool + want string + }{ + { + name: "flag on serves the new path", + flags: map[string]bool{featureflag.ExampleFlag: true}, + want: "new feature output", + }, + { + name: "flag off serves the default path", + flags: map[string]bool{featureflag.ExampleFlag: false}, + want: "default output", + }, + { + name: "flag absent defaults off", + flags: map[string]bool{}, + want: "default output", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + provider := featureflag.NewProvider(testCase.flags) + + client, err := featureflag.NewClient(testCase.name, provider) + if err != nil { + t.Fatalf("NewClient(%q) returned error: %v", testCase.name, err) + } + + got := featureflag.ExampleFeature(context.Background(), client) + if got != testCase.want { + t.Errorf("ExampleFeature() = %q, want %q", got, testCase.want) + } + }) + } +}