Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ on:
- main

jobs:
# Fast canary for the Provider TCK: runs the full applicable conformance suite against the
# SDK's InMemoryProvider, and again against MultiProvider wrapping one of them, with no
# Docker, no Compose stack and no network. It finishes in seconds, so a broken step
# definition, a mis-wired capability gate or a regression in the shared harness is reported
# long before the containerised provider suites in `main` get there — and it points at the
# TCK rather than at whichever provider noticed first.
#
# Deliberately not a gate on `main`: the two run in parallel so a green run is not delayed.
# The same suite also runs inside `main` as part of the reactor build; this job exists to
# report it fast and in isolation.
provider-tck:
name: Provider TCK (no Docker)
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
# No submodules: this module's feature files, flags and control-API spec are in-repo.

- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5
with:
java-version: 21
distribution: 'temurin'
cache: maven

- name: Cache local Maven repository
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}21-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}21-maven-

- name: Verify the TCK against the in-memory provider
# No `e2e` profile and no Docker: the in-memory suite is not gated behind either.
run: mvn --batch-mode --activate-profiles codequality -pl tools/provider-tck -am clean verify

main:
strategy:
matrix:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import dev.openfeature.contrib.providers.flagd.Config;
import dev.openfeature.contrib.providers.flagd.FlagdOptions;
import dev.openfeature.contrib.providers.flagd.FlagdProvider;
import dev.openfeature.contrib.tools.providertck.AbstractProviderTckTest;
import dev.openfeature.contrib.tools.providertck.BackendEndpoint;
import dev.openfeature.contrib.tools.providertck.Capability;
import dev.openfeature.contrib.tools.providertck.ContainerizedProviderTckTest;
import dev.openfeature.sdk.FeatureProvider;
import java.io.File;
import java.util.Collections;
Expand All @@ -24,7 +24,7 @@
* one is running from the JUnit test plan, so adding a mode needs no registration or build
* configuration.
*/
abstract class AbstractFlagdTckTest extends AbstractProviderTckTest {
abstract class AbstractFlagdTckTest extends ContainerizedProviderTckTest {

/**
* A port nothing listens on, for the initialisation-failure scenarios.
Expand Down
136 changes: 129 additions & 7 deletions tools/provider-tck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ contract" is an unverified claim. This is the shared suite that makes it checkab
```
<!-- x-release-please-end-version -->

Requires Java 11+, JUnit 5, and a working Docker daemon.
Requires Java 11+ and JUnit 5. A working Docker daemon is needed only for providers with an
external backend — see [Which base class to extend](#which-base-class-to-extend).

### OpenFeature SDK compatibility

Expand Down Expand Up @@ -55,8 +56,114 @@ uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `Pro
- the provider↔backend wire protocol. How you talk to your backend is your business.
- SDK behaviour. That belongs to the SDK's own test suite.

## Which base class to extend

Two, and the choice is made by one question: **does your provider talk to something outside the
JVM?**

| | Extend | Backend control | You supply |
|---|---|---|---|
| Provider has an external backend | `ContainerizedProviderTckTest` | `HttpBackendControl`, over the HTTP control API | a Compose stack, a control API, a test class |
| Provider has no backend — in-memory, environment variables, a local file | `ProviderTckTest` | an in-process `BackendControl` | a test class |

`ContainerizedProviderTckTest` is the normal case and everything in [Adopting it](#adopting-it)
below describes it. It extends `ProviderTckTest` and adds the Compose lifecycle, port discovery and
control API client on top.

### In-process control is for backend-less providers only

Step definitions never touch a backend directly. They go through one interface, `BackendControl`,
which is what lets the same Gherkin run against a container over HTTP and against an in-memory
provider manipulated in the same JVM.

That seam is not an invitation to skip the control API. **If your provider has an external backend,
use `HttpBackendControl` via `ContainerizedProviderTckTest`.** The control API described in
[`openapi/control-api.yaml`](src/main/resources/openapi/control-api.yaml) is the normative contract
for those providers, and it is the whole basis of a portable conformance claim: another language's
TCK drives the same endpoints against the same stack and must get the same answers.

A custom in-JVM `BackendControl` that reaches an external backend through a side channel — a
test-only admin client, a shared database handle, a static hook inside the provider — bypasses that
contract. It will pass, and it will prove nothing, because the path it exercised is not the path the
contract describes.

In-process control exists for providers that have **nothing to contract with**, where "the backend"
is a data structure in the same JVM. For those, flag operations are map updates and a configuration
change is the provider's own update mechanism emitting its own event.

### Adopting it without a backend

`InProcessBackendControl` implements this for the SDK's `InMemoryProvider`, seeded with the
canonical flag set. The entire adoption is three methods:

```java
public class MyProviderTckTest extends ProviderTckTest {

private final InProcessBackendControl control = new InProcessBackendControl();

@Override
public BackendControl backendControl() {
return control;
}

@Override
public FeatureProvider createProvider() {
return control.createProvider();
}

@Override
public Set<Capability> capabilities() {
return EnumSet.of(
Capability.EVENTS,
Capability.CONFIGURATION_CHANGE,
Capability.OBJECT,
Capability.STRICT_NUMERIC_TYPING);
}
}
```

One object backs both factory methods because in-process the flag store and the provider are the
same thing: `changeFlag()` has to reach the live provider instance to emit an event from it.

**Connection control does not apply**, and the capability declaration is where you say so rather
than stubbing it out. An in-memory provider has no connection to lose, so
`InProcessBackendControl` leaves `disconnect()`, `reconnect()` and `disconnectFor()` unimplemented —
they throw. Leaving `STALE` and `UNAVAILABLE_INIT` out of `capabilities()` is what keeps that
honest: the scenarios needing them are skipped before any step can reach an unsupported operation.

Get that pairing wrong — declare `STALE` against a control that cannot disconnect — and you get an
`UnsupportedOperationException` naming the fix, not a silent pass. That is deliberate. A
`BackendControl` may throw `UnsupportedOperationException` for operations it does not support, and
reaching one from a scenario that actually ran is a **test-configuration bug**, never a skip.

### The TCK's own self-tests

Two suites in this module are exactly the class above, and both run with no Docker in well under a
second. They are the reference adoption, and they are the fast CI canary.

[`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java)
runs the full applicable suite against the SDK's `InMemoryProvider` — 26 passed, 3 skipped by
capability.

[`MultiProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java)
runs it against `MultiProvider` wrapping **one** `InMemoryProvider`. A provider that delegates is
still a provider, and delegation is where the contract is easiest to drop: a variant that does not
survive the hop, a reason rewritten, an error code flattened, an event that never arrives. With a
single child the correct answer is precisely what the in-memory suite already asserts, so any
difference between the two suites is attributable to `MultiProvider` and nothing else.

That suite has already paid for itself. It does **not** declare `CONFIGURATION_CHANGE`, because
`MultiProvider` extends `EventProvider` but never subscribes to its children — a child's
`PROVIDER_CONFIGURATION_CHANGED`, `PROVIDER_ERROR` and `PROVIDER_STALE` are all swallowed. Wrapping
a provider in a multi-provider silently costs you those events, with nothing in the API to hint at
it. That is a known SDK gap,
[open-feature/java-sdk#1882](https://github.com/open-feature/java-sdk/issues/1882) (gap 1, High),
which the suite reproduced from the outside — the gap was originally found by hand-comparing
implementations against the js-sdk reference. Everything else survives delegation unchanged.

## Adopting it

This section describes a provider with an external backend — the common case.
Four things to implement, then two small files.

### 1. A Docker Compose stack
Expand Down Expand Up @@ -130,7 +237,7 @@ Two details are load-bearing:
### 4. The test class

```java
public class MyProviderTckTest extends AbstractProviderTckTest {
public class MyProviderTckTest extends ContainerizedProviderTckTest {

@Override
public File composeFile() {
Expand Down Expand Up @@ -172,7 +279,7 @@ registration, no system property, no build configuration. Each class is its own
own Compose stack, and they can share a base class:

```java
abstract class AbstractMyProviderTckTest extends AbstractProviderTckTest {
abstract class AbstractMyProviderTckTest extends ContainerizedProviderTckTest {
protected abstract Mode mode();
// composeFile(), createProvider(), capabilities() ... shared here
}
Expand Down Expand Up @@ -218,17 +325,23 @@ green on scenarios it did not run is worse than no suite at all.
| Capability | Tag | Meaning |
|---|---|---|
| `EVENTS` | `@events` | emits lifecycle events at all |
| `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss |
| `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss — *needs connection control* |
| `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` |
| `OBJECT` | `@object` | supports structured flag values |
| `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend |
| `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* |
| `STRICT_NUMERIC_TYPING` | `@strict-numeric-typing` | does not coerce between integer and float |
| `TARGETING` | `@targeting` | reserved, no scenarios yet |
| `CACHING` | `@caching` | reserved, no scenarios yet |

The default is every capability. **Narrow it, do not widen it**: start from the default, run the
suite, and remove only what your provider genuinely cannot do.

`STALE` and `UNAVAILABLE_INIT` are the two that need a backend the provider can be cut off from.
They are what a backend-less provider leaves undeclared — see
[In-process control is for backend-less providers only](#in-process-control-is-for-backend-less-providers-only).
Declaring one against a `BackendControl` that cannot simulate an outage fails the scenario with an
`UnsupportedOperationException` naming the fix, rather than passing it.

```java
@Override
public Set<Capability> capabilities() {
Expand All @@ -254,8 +367,8 @@ needs most of a poll interval. Every await timeout is therefore overridable.
|---|---|---|
| `eventTimeout()` | 12s | waiting for a provider event |
| `readyTimeout()` | 30s | waiting for a provider to reach a lifecycle state |
| `startupTimeout()` | 60s | bringing the Compose stack up |
| `settleTime()` | 50ms | pause after a control API call |
| `startupTimeout()` | 60s | bringing the Compose stack up (`ContainerizedProviderTckTest` only) |
| `settleTime()` | 50ms | pause after a control API call (`ContainerizedProviderTckTest` only) |

```java
@Override
Expand All @@ -274,6 +387,9 @@ use the explicit `within {int}ms` step, which always wins.
mvn test -Dtest=MyProviderTckTest
```

A suite extending `ProviderTckTest` with in-process control needs no Docker and no network. A suite
extending `ContainerizedProviderTckTest` needs a working Docker daemon for its Compose stack.

Scenarios run **serially** and the suite enforces this, overriding any
`cucumber.execution.parallel.enabled=true` in your module's `junit-platform.properties`. Control API
state is global to the Compose stack, so concurrent scenarios corrupt each other — one scenario's
Expand Down Expand Up @@ -335,6 +451,12 @@ consumers — the features stay on the classpath and stay inside the JAR.
`@targeting` tag is reserved for context-passthrough scenarios once the gap above is closed.
- **Caching.** Whether a stale provider keeps serving last-known values during an outage depends on
whether it holds a local copy of the ruleset. The `@caching` tag is reserved; no scenarios yet.
- **Setting and removing individual flags.** `BackendControl` exposes `prepareScenario()` and
`changeFlag()` — reset to the canonical baseline, and mutate `changing-flag` — because those are
what the Gherkin needs and what the control API defines. Finer-grained `setFlag(key, value)` /
`removeFlag(key)` operations would need control API endpoints that do not exist yet, so adding
them to the interface would produce methods `HttpBackendControl` could not implement. They belong
to a control API revision, not to the Java seam.
- **Hooks.** Not covered.
- **Flag metadata.** The flagd harness has metadata scenarios; they are not yet ported.
- **Multi-suite JVMs.** `TckRuntime` is static, so TCK suites run one at a time within a JVM fork.
Expand Down
31 changes: 24 additions & 7 deletions tools/provider-tck/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
<name>provider-tck</name>
<description>
Language-agnostic conformance test suite (TCK) for OpenFeature providers.
Bundles the canonical Gherkin feature files, Cucumber step definitions and an
abstract JUnit Platform Suite base class that owns the full test lifecycle:
starting the vendor-supplied Docker Compose stack, discovering dynamically
mapped ports, driving the standardised control API and awaiting provider
events. Provider authors implement a single factory interface.
Bundles the canonical Gherkin feature files, Cucumber step definitions and
abstract JUnit Platform Suite base classes that own the full test lifecycle.
Providers with an external backend extend ContainerizedProviderTckTest, which
starts the vendor-supplied Docker Compose stack, discovers dynamically mapped
ports and drives the standardised HTTP control API. Providers with no backend
extend ProviderTckTest and supply in-process backend control. Provider authors
implement a small factory interface either way.
</description>
<url>https://openfeature.dev</url>

Expand Down Expand Up @@ -97,7 +99,7 @@
</dependency>

<!-- Cucumber JUnit Platform Engine — compile scope for the GLUE_PROPERTY_NAME /
OBJECT_FACTORY_PROPERTY_NAME constants used in AbstractProviderTckTest -->
OBJECT_FACTORY_PROPERTY_NAME constants used in ProviderTckTest -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
Expand All @@ -110,7 +112,7 @@
<scope>compile</scope>
</dependency>

<!-- JUnit Platform Suite — compile scope so AbstractProviderTckTest can carry
<!-- JUnit Platform Suite — compile scope so ProviderTckTest can carry
@Suite/@IncludeEngines/@SelectClasspathResource/@ConfigurationParameter -->
<dependency>
<groupId>org.junit.platform</groupId>
Expand Down Expand Up @@ -180,6 +182,21 @@
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>

<!--
Test scope only, for this module's own self-tests. InMemoryProviderTckTest and
MultiProviderTckTest run the full applicable suite against the SDK's InMemoryProvider
and against MultiProvider wrapping one, with no container and no network;
InProcessBackendControlTest pins the behaviour the Gherkin cannot assert about
itself. The SDK is inherited from the parent POM as `provided`, which puts it on the
test classpath already.
-->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<!-- version managed by junit-bom in parent -->
<scope>test</scope>
</dependency>
</dependencies>

</project>
Loading
Loading