feat(provider-tck): add the Go conformance suite for OpenFeature providers - #940
feat(provider-tck): add the Go conformance suite for OpenFeature providers#940aepfli wants to merge 5 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
521f042 to
3755b42
Compare
…iders A conformance suite any Go provider can adopt to verify it implements the provider contract of the specification, and the Go implementation of the cross-language suite defined in Appendix F. The adoption surface is one function call and one struct literal. The TCK owns the whole lifecycle: registering the provider under a suite-scoped domain, waiting for readiness, awaiting events, resetting the backend between scenarios and releasing the provider at the end. Each scenario becomes a Go subtest. The canonical Gherkin and flag set are embedded in the module, so adopting it needs no git submodule. They are copies of open-feature/spec's specification/assets/provider-tck/ and are marked as such; a follow-up will source them from a submodule at build time. Capability gating uses godog.ErrSkip from the Before hook, so a scenario whose capability was not declared is skipped rather than failed, and every skip is reported with its reason. A conformance suite that goes green on scenarios it did not run is worse than no suite at all. Three self-tests run against SDK providers with no Docker: memprovider, the TCK's own updatable in-memory provider, and multi.Provider wrapping one child. The multi-provider suite wraps exactly one child on purpose - the correct answer is what the single-provider suite already asserts, so any difference is attributable to delegation alone. Two Go-specific translations of the shared Gherkin, both documented in the README: "no exception should have been thrown" asserts the evaluation did not panic, because a returned error is the normal shape of an errored evaluation in Go; and providers are registered under a suite-scoped rather than per-scenario domain, so each registration shuts the previous provider down instead of leaking one connection per scenario. Finding: the Go SDK's memprovider.InMemoryProvider cannot update its flag set and emits no events, which Appendix A requires of an SDK in-memory provider and which the JS and Java SDKs both implement. The in-memory suite therefore leaves @configuration-change undeclared and reports it as skipped with the reason; tck.ControllableProvider supplies the missing behaviour by wrapping the SDK's provider, so it doubles as a reference for the fix. Part of open-feature/spec#417 Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
3755b42 to
55e90a3
Compare
|
Hey @aepfli Could we rename Also, could we make it a flat package instead of putting it under |
The conformance artifacts were vendored under pkg/tck/assets/ as copies of specification/assets/provider-tck/ in open-feature/spec. A copy can be edited in place, and an edited copy forks the definition of conformance -- which is the one thing this suite exists to prevent. Worse, nothing recorded which revision of the specification the copy was taken from, so "conformant" had no version attached to it. Replace the copies with a git submodule of open-feature/spec at tools/provider-tck/pkg/tck/spec, pinned at dfa16586, and point the //go:embed directives straight at paths inside it. Go embeds real files relative to the package directory, so there is no copy step, no generator and nothing to keep in sync: the specification revision this suite conforms to is the submodule pin, and moving the pin is the only way to change it. Adopters are unaffected and still need no submodule of their own -- the assets are compiled into the package. Contributors to this module need --recurse-submodules, and so does CI: the lint job checked out without submodules, which would have failed the embed at compile time, so it gains submodules: recursive alongside the test job that already had it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
lifecycle.feature was gated by @events, which is wrong in both directions, and the spec revision this module now pins retags it to @lifecycle. Too lax: the Go SDK synthesises PROVIDER_READY for any provider that does not implement openfeature.StateHandler -- "a provider without state handling capability can be assumed to be ready immediately" -- so declaring @events was enough to pass the readiness scenario without demonstrating anything. A NoopProvider passes it identically, which is precisely the silent green this suite exists to make impossible. Too strict: a stateless HTTP provider such as OFREP emits no events of its own and so cannot declare @events, yet the readiness scenario is not really about events. Withholding @events skipped it for the wrong reason, and nothing in the vocabulary let such a provider say what it actually lacked. Add tck.Lifecycle for the distinct claim -- performs an initialisation that reaches its backend, with an observable outcome -- and revisit the three self-test declared sets, which is where the vacuous pass shows up concretely. TestControllableProvider keeps it: tck.ControllableProvider implements StateHandler, so its READY comes out of its own Init, and it is the only self-test that covers the @lifecycle steps without Docker. TestInMemoryProvider and TestMultiProvider drop it: memprovider.InMemoryProvider is not a StateHandler, there is no backend to reach on either side, and both had been passing the readiness scenario on the strength of @events alone. They now report it as skipped with the reason, which is the honest outcome. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…he submodule Embedding straight from the spec submodule works locally and in CI and then fails for anyone running `go get`. A Go module is distributed as a zip built from the VCS tree, where a submodule appears only as a gitlink, so the module would ship an empty directory and the package would not compile. Verified without publishing anything: $ git archive HEAD tools/provider-tck | tar -t | grep 'pkg/tck/spec' tools/provider-tck/pkg/tck/spec/ $ git archive HEAD tools/provider-tck | tar -t | grep -c '\.feature$' 0 So the submodule stays the source of truth and keeps recording the spec revision, sync_assets.go copies the artifacts into the package, and the copies are committed and embedded. This is a Go-specific concession: a wheel, a JAR and an npm package are all built from a working tree where the submodule is present, so the other three implementations do not need it. A committed generated file is a lie waiting to happen, so `make provider-tck-assets-check` regenerates and fails on any diff, and CI runs it. Hand-editing a feature file is therefore caught, which is the property that matters: the definition of conformance must not be able to drift. Verified with Go 1.25 under WSL: gofmt clean, go vet clean, go test ok. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Hey, Sure thing, this is just me drafting and testing with Claude code, for multiple languages, and I try to stick to how the repo is handling things. The naming is another question. What if there is a tck for other areas (hooks, listeners - bad examples as they are just interfaces currently) should we be ready for this? |
…eck platform-safe Two related problems, both found by running things rather than reading them. The sync check would have failed spuriously on Windows. sync_assets.go copies bytes verbatim, so it writes the LF endings the spec repository pins, but the generated directory carried no .gitattributes -- so a fresh clone with core.autocrlf=true, the Windows default, checks the artifacts out as CRLF and the check then reports a diff on a tree nobody touched. It was latent here only because the files had been generated rather than checked out. Placing a .gitattributes beside them by hand does not work either: the generator wipes and rebuilds the whole directory, so the file vanished on the next run. It is now emitted by the generator, which is the honest arrangement -- everything under assets/ is generated, including the rule that keeps it stable across platforms. That also keeps the embedded bytes identical to every other language's copy, which is the property the digest in the conformance report schema exists to check. The generator itself is portable: pure Go, path/filepath, and byte-exact reads and writes with no text-mode translation, so it behaves the same on Windows, Linux and macOS. Verified: go test ok, sync is idempotent, and the check passes on a Windows working tree with autocrlf enabled. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
It really depends on the implementation. If |
Closes #938
Part of open-feature/spec#417
What this is
A conformance suite any Go provider can adopt to verify it implements the provider contract of the specification — the Go implementation of Appendix F, running the same Gherkin scenarios, against the same canonical flag set, driven through the same control API as every other language's TCK. That shared basis is the point: "conformant" only means something if the question is identical everywhere.
Java is the reference (java-sdk-contrib#1830 and its stack). This is not a transcription of it — Go has no inheritance, no JUnit Platform Suite and no exceptions, and each of those forced a decision rather than a translation.
The adoption surface
One function call, one struct literal. Three required fields.
The TCK owns the whole lifecycle: registering the provider, waiting for readiness, awaiting events, resetting the backend between scenarios, releasing the provider at the end. Each scenario becomes a Go subtest, so
-runselects one and failures name a scenario. The canonical Gherkin and flag set arego:embed-ed, so consumers need no git submodule.Configis a struct rather than an interface because Go has no inheritance and an interface with six optional methods is worse on every axis that matters here. The trade is compile-time enforcement for discoverability;Runvalidates up front and names what is missing.Three Go-specific decisions
1. "no exception should have been thrown" asserts the evaluation did not panic. Go has no exceptions, and the returned
erroris not one — an errored evaluation correctly returns the code default alongside a non-nil error, which is the normal shape of the API. The behaviour the feature files forbid — an unhandled failure escaping a flag evaluation and taking the host application down — is a panic here. Provider registration is guarded the same way.2. Providers are registered under a suite-scoped domain, not a per-scenario one. Registering in a domain replaces and shuts down the previous provider. A fresh domain per scenario reads cleaner but would leave every provider of the suite registered and running — for a provider holding a network connection, one leaked connection per scenario.
3. Capability gating is
godog.ErrSkipreturned from theBeforehook. godog treats that as "skip this scenario and all its steps" rather than a failure, anderrors.Isstill matches after godog wraps it. Every skip is also collected and printed with its reason at the end of the suite, because a conformance suite that quietly goes green on scenarios it did not run is worse than no suite at all.There is a non-obvious constraint the design depends on: the SDK compares providers with
reflect.DeepEqualunless the dynamic type is a pointer. A value-typed provider holding an equal flag set would compare as the same provider, and the per-scenario replacement the TCK relies on for isolation would silently not happen.ControllableProvideris therefore pointer-only, and says so.Self-tests: three suites, no Docker
TestInMemoryProvidermemprovider.InMemoryProviderTestControllableProvidertck.ControllableProviderTestMultiProvidermulti.Providerwrapping one childTestMultiProviderwraps exactly one child deliberately. That is the interesting configuration rather than a degenerate one: the correct answer is precisely whatTestControllableProvideralready asserts about the child alone, so any difference between the two suites is attributable to the multi-provider and nothing else — a variant that does not survive the hop, a reason rewritten toDEFAULT, an error code flattened toGENERAL, an event that never reaches the client.Alongside them,
gate_internal_test.gopins the capability gate directly rather than by inference from a scenario count, including that the error returned from theBeforehook is one godog treats as a skip.Finding: the Go SDK's in-memory provider cannot update its flag set
Appendix A is unambiguous about what an SDK's in-memory provider must do:
memprovider.InMemoryProviderisMetadata+ five evaluations +Hooks+Track. No update method, not anEventHandler, not even aStateHandler.PROVIDER_CONFIGURATION_CHANGEDputConfiguration()updateFlag()The knock-on is not confined to this suite: Appendix A also requires SDK end-to-end tests to use the in-memory provider, so go-sdk's own Appendix B suite cannot cover configuration-change events either.
This is handled honestly rather than worked around.
TestInMemoryProviderleavesConfigurationChangeundeclared, so the scenario is reported as skipped with the reason, andplainMemoryControl.ChangeFlagreturns an error stating the gap in case anyone declares it anyway.tck.ControllableProvidersupplies the missing behaviour by wrapping the SDK's provider rather than reimplementing it — every resolution decision is still made bymemprovider— so it doubles as a reference for what the SDK's provider should grow. Tracked as open-feature/go-sdk#530.Finding it before the suite had run once is a reasonable advertisement for what a conformance suite is for.
Verification
Being precise about this, because the usual table would be misleading.
Authored without a toolchain; these are CI's results, not local ones.
make e2e(go test -tags=e2e, all modules)golangci-lintST1008: error should be returned as the last argumentinregisterProviderTestSuite,Options{FS, TestingT, Concurrency, Strict},ErrSkipsemantics insuite.go,(context.Context, error)step returns ininternal/models/stepdef.go(including that the context must not be nil)SetNamedProviderWithContextAndWait,Client.AddHandler/RemoveHandler/State,EventCallbackas*func(EventDetails),ResolutionDetailfields,ErrorCodeconstants,emitOnRegistrationreplay-on-registration,providerReference.equalspointer-vs-DeepEqual behaviourcmpfor all six files, and.gitattributespins LF so a Windows checkout cannot embed different bytesgo.sumsum.golang.orgfor the rest of the closure, computed by walking each dependency'sgo.modfromproxy.golang.org. It resolved cleanly in CI. Still worth ago mod tidybefore merging, to normalise it to what the toolchain would write.dupword/errcheckhazardsI had expected a missing
go.sumentry to be the likeliest failure. It was not — the assembled checksums resolved and the build ran clean. The single real defect was the return-value ordering above.Known gaps
pkg/tck/assets/is a copy ofspecification/assets/provider-tck/from spec#423, marked as such inassets.go. A follow-up will source them from a submodule at build time, the wayproviders/flagdalready consumes the spec repo — the equivalent of java-sdk-contrib#1838.POST /restartis unused — no scenario needs a bounded outage, soConnectionControlhas noDisconnectFor.Open questions
Configstruct the right adoption surface for Go, or should this be an interface with aBaseHarnessto embed?tools/provider-tckthe right home, giventests/in this repo already means "shared test harness module" (tests/flagd)? It was chosen to mirror the Java module path.ControllableProviderhere right, or should the fix land in go-sdk first and this module depend on it?