diff --git a/libs/providers/flagd/src/e2e/jest.config.ts b/libs/providers/flagd/src/e2e/jest.config.ts index 187f4362a..c58abc8cb 100644 --- a/libs/providers/flagd/src/e2e/jest.config.ts +++ b/libs/providers/flagd/src/e2e/jest.config.ts @@ -6,6 +6,7 @@ const config: Config = { preset: 'ts-jest', moduleNameMapper: { '@openfeature/flagd-core': ['/../../../../shared/flagd-core/src'], + '@openfeature/provider-tck': ['/../../../../shared/provider-tck/src'], '(.+)\\.js$': '$1', }, verbose: true, diff --git a/libs/providers/flagd/src/e2e/tests/tck-in-process.spec.ts b/libs/providers/flagd/src/e2e/tests/tck-in-process.spec.ts new file mode 100644 index 000000000..fbf7744fb --- /dev/null +++ b/libs/providers/flagd/src/e2e/tests/tck-in-process.spec.ts @@ -0,0 +1,42 @@ +import { Capability } from '@openfeature/provider-tck'; +import { runFlagdTck } from './tckSuite'; + +/** + * The OpenFeature Provider Conformance Suite, run against the flagd provider's in-process resolver. + * + * One `runProviderTck` call per file — see the note in `tckSuite.ts`. + */ +runFlagdTck({ + name: 'flagd-in-process', + resolverType: 'in-process', + + /* + * The same five capabilities as the RPC suite, and that identity is the finding rather than a + * copy-paste: in Go the two resolvers differ over PROVIDER_STALE (go-sdk-contrib#939), here they + * cannot, because both report a lost connection through the same `disconnectCallback` seam — + * src/lib/service/in-process/grpc/grpc-fetch.ts:197 here, src/lib/service/grpc/grpc-service.ts:274 + * for RPC — and the single handler behind it, src/lib/flagd-provider.ts:130-148, emits + * PROVIDER_STALE (flagd-provider.ts:136) before escalating to PROVIDER_ERROR + * (flagd-provider.ts:144). + * + * The omissions are the same and have the same reasons: StrictNumericTyping because JavaScript has + * no integer type, so the scenario is unsatisfiable by construction (see the TCK README), and + * Targeting and Caching because no scenario carries their tags yet. + * + * One in-process detail worth recording: the file/offline fetcher — + * src/lib/service/in-process/file/file-fetch.ts — never calls `disconnectCallback` and so emits + * neither PROVIDER_STALE nor a reconnect PROVIDER_READY. That mode is not under test here; a suite + * covering `offlineFlagSourcePath` would have to leave Stale undeclared. + */ + capabilities: [ + Capability.Events, + Capability.Stale, + Capability.ConfigurationChange, + Capability.Object, + Capability.UnavailableInit, + ], + + // In-process syncs the whole ruleset before reporting ready, so it needs longer than RPC. + readyTimeoutMs: 60_000, + retryGracePeriod: 30, +}); diff --git a/libs/providers/flagd/src/e2e/tests/tck-rpc.spec.ts b/libs/providers/flagd/src/e2e/tests/tck-rpc.spec.ts new file mode 100644 index 000000000..f7ae02ad0 --- /dev/null +++ b/libs/providers/flagd/src/e2e/tests/tck-rpc.spec.ts @@ -0,0 +1,46 @@ +import { Capability } from '@openfeature/provider-tck'; +import { runFlagdTck } from './tckSuite'; + +/** + * The OpenFeature Provider Conformance Suite, run against the flagd provider's RPC resolver. + * + * One `runProviderTck` call per file — see the note in `tckSuite.ts`. + */ +runFlagdTck({ + name: 'flagd-rpc', + resolverType: 'rpc', + + /* + * Five capabilities, and every omission is derived from the provider's source rather than assumed: + * + * - Stale IS declared, and that is worth stating plainly because the Go provider is different. In + * Go, the RPC resolver never emits PROVIDER_STALE while in-process does + * (go-sdk-contrib#939). Here there is no such asymmetry: both resolvers report a lost connection + * through the same `disconnectCallback` seam — src/lib/service/grpc/grpc-service.ts:274 for RPC, + * src/lib/service/in-process/grpc/grpc-fetch.ts:197 for in-process — and the single handler + * behind it, src/lib/flagd-provider.ts:130-148, emits PROVIDER_STALE immediately + * (flagd-provider.ts:136) and escalates to PROVIDER_ERROR only once `retryGracePeriod` expires + * (flagd-provider.ts:144). The staleness contract is implemented once, in the provider, so it + * cannot differ between resolvers. + * - UnavailableInit is declared: an unreachable backend rejects out of + * `waitForReady` (grpc-service.ts:194-202), which rejects `connect` and therefore `initialize`. + * - ConfigurationChange is declared, and the event carries the changed keys — + * grpc-service.ts:243 derives them from the flagd change message and flagd-provider.ts:151 + * puts them in the payload as `flagsChanged`, which the suite asserts on. + * - StrictNumericTyping is omitted for the reason every JavaScript provider omits it: the language + * has no integer type, so the scenario is unsatisfiable by construction rather than by defect. + * See "The one place JavaScript cannot answer the shared question" in the TCK README. + * - Targeting and Caching are omitted because no scenario carries their tags yet. + */ + capabilities: [ + Capability.Events, + Capability.Stale, + Capability.ConfigurationChange, + Capability.Object, + Capability.UnavailableInit, + ], + + // The RPC resolver asks flagd to resolve each flag, so it is ready as soon as the stream is up. + readyTimeoutMs: 30_000, + retryGracePeriod: 30, +}); diff --git a/libs/providers/flagd/src/e2e/tests/tckSuite.ts b/libs/providers/flagd/src/e2e/tests/tckSuite.ts new file mode 100644 index 000000000..d7acf8dc9 --- /dev/null +++ b/libs/providers/flagd/src/e2e/tests/tckSuite.ts @@ -0,0 +1,132 @@ +import type { Capability } from '@openfeature/provider-tck'; +import { HttpControl, runProviderTck } from '@openfeature/provider-tck'; +import type { ResolverType } from '../../lib/configuration'; +import { FlagdProvider } from '../../lib/flagd-provider'; +import { FlagdComposeContainer } from './flagdComposeContainer'; + +/** + * The shared body of the two flagd conformance suites. + * + * flagd resolves flags two quite different ways — RPC evaluates remotely over gRPC, in-process syncs + * the ruleset and evaluates locally — and they are separate suites because they are separately + * conformant. Any difference between the two results is a difference an application would see when + * it switches resolver, which is exactly the kind of thing the suite exists to surface. + * + * They are separate **files** for a mechanical reason on top of that: jest-cucumber accumulates step + * definitions in module state, so two `runProviderTck` calls in one file would register the + * vocabulary twice and every step would report as ambiguous. + * + * The existing e2e suites in this directory are untouched, and so is flagd-testbed. The TCK drives + * the testbed's launchpad through the standardised control API, which the launchpad already + * implements. + */ +export interface FlagdTckSuite { + /** Identifies the suite in test output and scopes its OpenFeature domain. */ + name: string; + + /** Which resolver is under test. Also selects the container port the provider connects to. */ + resolverType: ResolverType; + + /** What the resolver supports, and — more importantly — what it does not. */ + capabilities: readonly Capability[]; + + /** How long the provider may take to reach READY. */ + readyTimeoutMs: number; + + /** + * Seconds the provider stays STALE before escalating to ERROR. + * + * It has to outlast the outage in the `@stale` scenario, which lasts as long as the suite takes to + * assert the stale event and then call `/start` — bounded by {@link EVENT_TIMEOUT_MS}. Too short a + * value would turn a scenario about staleness into one about failure. + */ + retryGracePeriod: number; +} + +/** + * How long to wait for a provider event. + * + * flagd streams, so it sees an outage or a configuration change in well under a second; this is + * headroom for a loaded CI machine rather than an expected latency. + */ +const EVENT_TIMEOUT_MS = 15_000; + +/** Bounds a single scenario, which may register a provider and then await several events. */ +const SCENARIO_TIMEOUT_MS = 120_000; + +/** Bounds bringing the Compose stack up, which on a cold machine includes pulling the image. */ +const STACK_TIMEOUT_MS = 180_000; + +export function runFlagdTck(suite: FlagdTckSuite): void { + jest.setTimeout(SCENARIO_TIMEOUT_MS); + + // Deliberately no jest.retryTimes, unlike the neighbouring flagd e2e suites: a conformance result + // that only holds on the third attempt is not a conformance result. If a scenario is flaky here, + // the timings above are the knob, or the flakiness is the finding. + + // The stack is started once for the whole suite and never restarted. Scenario isolation comes from + // the control API instead — see the no-container-restart invariant in the control API + // specification. Registered at the file's root scope, so it runs before the `beforeEach` that + // runProviderTck installs inside its own describe block. + const container = FlagdComposeContainer.build(); + + beforeAll(async () => { + await container.start(); + }, STACK_TIMEOUT_MS); + + afterAll(async () => { + // Guarded because stop() throws on a stack that never came up, which would bury the startup + // failure that is the actual finding under a second, misleading one. + if (container.isStarted()) { + await container.stop(); + } + }, STACK_TIMEOUT_MS); + + // The launchpad's host port is mapped dynamically and does not exist until the stack is up, while + // runProviderTck must be called at module load — hence the thunk. getLaunchpadUrl() returns + // "host:port" with no scheme, and the control API address needs one. + const control = new HttpControl({ baseUrl: () => `http://${container.getLaunchpadUrl()}` }); + + runProviderTck({ + name: suite.name, + control, + + // Read inside the factory, not above it: the testbed maps host ports dynamically, so they do not + // exist until the stack is up. They stay valid for the whole suite because nothing restarts a + // container. + // + // The timings other than retryGracePeriod are the ones the neighbouring flagd e2e suites already + // use, where they are described as optimised for test speed and stability. + newProvider: () => + new FlagdProvider({ + resolverType: suite.resolverType, + host: 'localhost', + port: container.getPort(suite.resolverType), + deadlineMs: 15000, + keepAliveTime: 200, + retryBackoffMs: 100, + retryBackoffMaxMs: 500, + retryGracePeriod: suite.retryGracePeriod, + }), + + // Pointed at a closed port on localhost, never at the backend under test — that has to stay up, + // and simulated outages belong to the control API. The deadlines are deliberately short: the + // scenario asserts that failure is reported promptly, so a provider that took 30 seconds to give + // up would pass a test about eventual failure and fail the one that matters. A one-second grace + // period is what turns the initial STALE into the ERROR the scenario waits for. + newUnavailableProvider: () => + new FlagdProvider({ + resolverType: suite.resolverType, + host: 'localhost', + port: 9999, + deadlineMs: 500, + retryBackoffMs: 100, + retryBackoffMaxMs: 500, + retryGracePeriod: 1, + }), + + capabilities: suite.capabilities, + readyTimeoutMs: suite.readyTimeoutMs, + eventTimeoutMs: EVENT_TIMEOUT_MS, + }); +} diff --git a/libs/shared/provider-tck/README.md b/libs/shared/provider-tck/README.md index 8d1aa9030..79cdbba32 100644 --- a/libs/shared/provider-tck/README.md +++ b/libs/shared/provider-tck/README.md @@ -145,7 +145,26 @@ the normative contract for those providers, and it is what makes a conformance c another language's TCK drives the same endpoints against the same stack and must get the same answers. -Two of its requirements are easy to get wrong: +`HttpControl` is the client for it, and it implements both `BackendControl` and `ConnectionControl` +over the global `fetch`, so it adds no dependency. It takes a thunk for the base URL because the +control service's host port is mapped dynamically and does not exist until the stack is up, whereas +`runProviderTck` has to be called at module load: + +```ts +const container = MyComposeStack.build(); +beforeAll(() => container.start()); +afterAll(() => container.stop()); + +const control = new HttpControl({ baseUrl: () => `http://${container.getControlUrl()}` }); +``` + +It prefers `POST /reset` for scenario isolation and falls back to `POST /start?config=default` on a +`404` or `501`, caching that decision once per suite. The fallback is the normal path rather than an +edge case — flagd-testbed's launchpad, the reference implementation, serves only `/start`, +`/restart`, `/stop` and `/change`. After a disconnect it always uses `/start`, because `/reset` +restores flag state and is not specified to start a stopped backend. + +Two of the API's requirements are easy to get wrong: - **Containers are never stopped or restarted mid-suite.** Unavailability is simulated *inside* the running stack. Container orchestrators assign host ports dynamically and cannot reliably preserve @@ -176,6 +195,7 @@ the same reason as the other two: there is no backend for initialisation to reac | `inMemory.spec.ts` | the SDK's `InMemoryProvider` | reference adoption for a backend-less provider, and the Docker-free canary | | `multiProvider.spec.ts` | `MultiProvider` wrapping one child | delegation must be transparent | | `inProcessControl.spec.ts` | `InProcessControl` | pins what the Gherkin cannot assert about itself | +| `httpControl.spec.ts` | `HttpControl` | pins the control-API request sequence, without a container | `multiProvider.spec.ts` wraps exactly one child deliberately. That is the interesting configuration rather than a degenerate one: the correct answer is precisely what the in-memory suite already @@ -220,7 +240,6 @@ by every language's TCK. ## Known gaps -- **No HTTP control client yet** — it arrives with the first containerised adopter. - **Evaluation context passthrough is unverifiable** without an echo operation on the control API. - Caching, hooks and flag metadata are not covered. diff --git a/libs/shared/provider-tck/src/index.ts b/libs/shared/provider-tck/src/index.ts index 7254f51ec..765e6a5b3 100644 --- a/libs/shared/provider-tck/src/index.ts +++ b/libs/shared/provider-tck/src/index.ts @@ -25,6 +25,8 @@ export { asConnectionControl, unsupportedControl } from './lib/control'; export type { BackendControl, ConnectionControl } from './lib/control'; export { CHANGING_BASELINE, CHANGING_CHANGED, CHANGING_FLAG_KEY, canonicalFlagSet } from './lib/flags'; export type { FlagConfiguration } from './lib/flags'; +export { DEFAULT_CONFIGURATION, DEFAULT_CONTROL_TIMEOUT_MS, HttpControl } from './lib/httpControl'; +export type { HttpControlOptions } from './lib/httpControl'; export { InProcessControl } from './lib/inProcessControl'; export { DEFAULT_EVENT_TIMEOUT_MS, DEFAULT_READY_TIMEOUT_MS, domainFor } from './lib/options'; export type { ProviderFactory, TckOptions } from './lib/options'; diff --git a/libs/shared/provider-tck/src/lib/httpControl.spec.ts b/libs/shared/provider-tck/src/lib/httpControl.spec.ts new file mode 100644 index 000000000..cb6eb5ae3 --- /dev/null +++ b/libs/shared/provider-tck/src/lib/httpControl.spec.ts @@ -0,0 +1,133 @@ +import { asConnectionControl } from './control'; +import { HttpControl } from './httpControl'; + +/** + * Things the Gherkin cannot assert about itself. + * + * The control API's fallback rules are invisible from inside a scenario: a suite whose control + * client gets them wrong still runs every scenario, and the failures it produces look like provider + * defects rather than test-harness ones. So the request sequence is pinned here, against a stubbed + * `fetch`, with no container involved. + */ +describe('HttpControl', () => { + const BASE = 'http://localhost:32768'; + + let calls: string[]; + let statuses: Map; + + beforeEach(() => { + calls = []; + statuses = new Map(); + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + calls.push(`${init?.method ?? 'GET'} ${url}`); + return new Response(null, { status: statuses.get(new URL(url).pathname) ?? 200 }); + }) as unknown as typeof fetch; + }); + + const control = () => new HttpControl({ baseUrl: BASE }); + + it('uses /reset for every scenario when the backend implements it', async () => { + // /reset causes no availability blip, so unlike /start it cannot inject a spurious lifecycle + // event into the scenario that follows. + const subject = control(); + + await subject.prepareScenario(); + await subject.prepareScenario(); + + expect(calls).toEqual([`POST ${BASE}/reset`, `POST ${BASE}/reset`]); + }); + + it('falls back to /start when /reset is not implemented, and remembers', async () => { + // The normal path rather than an edge case: flagd-testbed's launchpad, the reference + // implementation, serves only /start, /restart, /stop and /change, so /reset answers 404. + statuses.set('/reset', 404); + const subject = control(); + + await subject.prepareScenario(); + await subject.prepareScenario(); + + expect(calls).toEqual([ + `POST ${BASE}/reset`, + `POST ${BASE}/start?config=default`, + // No second probe: the decision is cached for the rest of the suite. + `POST ${BASE}/start?config=default`, + ]); + }); + + it('treats 501 the same as 404', async () => { + statuses.set('/reset', 501); + const subject = control(); + + await subject.prepareScenario(); + + expect(calls).toEqual([`POST ${BASE}/reset`, `POST ${BASE}/start?config=default`]); + }); + + it('starts rather than resets after a disconnect', async () => { + // The load-bearing one. /reset restores flag state and is not specified to start a stopped + // backend, so a scenario following an outage that only reset would run against a backend that is + // still down, and every one of its assertions would be reported as a provider defect. + const subject = control(); + + await subject.prepareScenario(); + await subject.disconnect(); + await subject.prepareScenario(); + + expect(calls).toEqual([`POST ${BASE}/reset`, `POST ${BASE}/stop`, `POST ${BASE}/start?config=default`]); + }); + + it('reconnects by starting the configuration already in effect', async () => { + // An outage must be observable as a change in availability and never as a change in flag values, + // which is what starting the same configuration guarantees. + const subject = new HttpControl({ baseUrl: BASE, configuration: 'ssl' }); + + await subject.reconnect(); + + expect(calls).toEqual([`POST ${BASE}/start?config=ssl`]); + }); + + it('fails loudly on an unexpected status', async () => { + // Silence here would be the worst outcome: the scenario would run against a backend in an + // unknown state and report whatever it found as a conformance result. + statuses.set('/change', 500); + + await expect(control().changeFlag()).rejects.toThrow(/returned 500/); + }); + + it('resolves the base URL lazily, so a mapped host port need not exist yet', () => { + // runProviderTck is called at module load, before any beforeAll has run, while the control + // service's host port is only assigned once the stack comes up — which is also when the typical + // container helper stops throwing. + let address: string | undefined; + const subject = new HttpControl({ + baseUrl: () => { + if (!address) { + throw new Error('the stack is not up yet'); + } + return `http://${address}`; + }, + }); + + // Constructing it must not have called the thunk, and reporting must not fail because of it. + expect(subject.description).toContain('not resolved yet'); + + address = 'localhost:32768'; + expect(subject.description).toContain(BASE); + }); + + it('rejects a base URL with no scheme', async () => { + // The mistake this catches is specific: container helpers commonly hand back "host:port", which + // URL parsing happily reads as a scheme of its own rather than rejecting. + const subject = new HttpControl({ baseUrl: 'localhost:32768' }); + + await expect(subject.changeFlag()).rejects.toThrow(/must use http or https/); + }); + + it('can simulate an outage', () => { + // The mirror of the InProcessControl assertion that it cannot: declaring Capability.Stale is + // only honest if the control behind it can actually take the backend away. + expect(asConnectionControl(control())).toBeDefined(); + }); +}); diff --git a/libs/shared/provider-tck/src/lib/httpControl.ts b/libs/shared/provider-tck/src/lib/httpControl.ts new file mode 100644 index 000000000..cd812f734 --- /dev/null +++ b/libs/shared/provider-tck/src/lib/httpControl.ts @@ -0,0 +1,251 @@ +import type { BackendControl, ConnectionControl } from './control'; + +/** + * The configuration name every backend under test must support, and the one that serves the + * canonical flag set. + */ +export const DEFAULT_CONFIGURATION = 'default'; + +/** + * How long a single control-API request may take. + * + * Control calls are local HTTP to a container on the same host; anything slower than this is a + * wedged backend rather than a slow one. + */ +export const DEFAULT_CONTROL_TIMEOUT_MS = 30_000; + +/** Configures an {@link HttpControl}. */ +export interface HttpControlOptions { + /** + * The root of the control API, scheme included — for example `http://localhost:32768`. + * + * It must be built from the **dynamically mapped host port** of the control service, which does + * not exist until the stack is up. `runProviderTck` has to be called at module load, before any + * `beforeAll` has run, so a plain string is usually impossible to supply. Hence the thunk form: + * + * ```ts + * const control = new HttpControl({ baseUrl: () => `http://${container.getLaunchpadUrl()}` }); + * ``` + * + * It is resolved once, on the first control call, and reused for the rest of the suite — which is + * safe precisely because no container is ever restarted mid-suite. + */ + baseUrl: string | (() => string); + + /** + * The named flag configuration to seed. + * + * Defaults to {@link DEFAULT_CONFIGURATION}, the only name every backend must support and the one + * serving the canonical flag set. + */ + configuration?: string; + + /** How long a single control-API request may take. @default 30000 */ + requestTimeoutMs?: number; +} + +/** + * A {@link BackendControl} that drives a backend under test over the HTTP control API defined in + * `openapi/control-api.yaml`. + * + * This is the normative control path for any provider with a real backend, and it is what makes a + * conformance claim portable: another language's TCK drives the same endpoints against the same + * stack and must get the same answers. It uses the global `fetch`, so it adds no dependency. + * + * ## What it never does + * + * It never stops, kills or recreates a container. Unavailability is simulated inside the running + * stack, through `POST /stop`, because container orchestrators assign host ports dynamically and + * cannot reliably preserve them across a restart — a restarted backend generally comes back on a + * different host port, silently invalidating every provider already pointed at the old one, and the + * resulting failure looks like a flaky provider. Starting and stopping the stack itself belongs to + * the adopting suite, once per suite. + * + * ## Scenario isolation + * + * {@link prepareScenario} prefers `POST /reset`, which restores the flag baseline with no + * availability blip and therefore cannot inject a spurious lifecycle event into the next scenario. + * That operation is optional, and a backend that does not implement it answers `404` or `501`; the + * TCK then falls back to `POST /start?config=...`, which also resets flag state at the cost of a + * process restart. The fallback is probed once and remembered for the rest of the suite. + * + * The fallback is the normal path today rather than an edge case: flagd-testbed's launchpad — the + * reference implementation the control API was derived from — serves only `/start`, `/restart`, + * `/stop` and `/change`. + * + * After a disconnect the backend may be down, and `/reset` is specified to reset flag state rather + * than to start a stopped backend. `HttpControl` tracks that and uses `/start` for the scenario + * following any disconnect. + */ +export class HttpControl implements BackendControl, ConnectionControl { + private readonly resolveBaseUrl: () => string; + private readonly configuration: string; + private readonly requestTimeoutMs: number; + + /** The resolved, trailing-slash-free base URL; `undefined` until the first control call. */ + private baseUrl: string | undefined; + + /** `undefined` until the first `/reset` call tells us whether the backend implements it. */ + private resetSupported: boolean | undefined; + + /** + * Records that a disconnect happened, so the next {@link prepareScenario} starts the backend + * rather than merely resetting flag state. + */ + private backendMaybeDown = false; + + constructor(options: HttpControlOptions) { + const { baseUrl, configuration, requestTimeoutMs } = options; + + if (!baseUrl) { + throw new Error( + 'HttpControlOptions.baseUrl is required: it is the root of the control API, built from ' + + 'the dynamically mapped host port of the control service', + ); + } + + this.resolveBaseUrl = typeof baseUrl === 'function' ? baseUrl : () => baseUrl; + this.configuration = configuration ?? DEFAULT_CONFIGURATION; + this.requestTimeoutMs = requestTimeoutMs ?? DEFAULT_CONTROL_TIMEOUT_MS; + } + + get description(): string { + // Read while reporting, including in failure messages, which may be before the stack is up. + // Reporting an address we do not have yet is not worth failing a test over. + let target: string; + try { + target = this.base(); + } catch { + target = 'the backend (control API address not resolved yet)'; + } + return `${target}, driven over the control API`; + } + + /** + * Brings the backend back to the baseline, preferring `/reset` and falling back to `/start`. + * + * See the class documentation for why the choice is made this way, and why a scenario following a + * disconnect always uses `/start`. + */ + async prepareScenario(): Promise { + // A backend that may be stopped has to be started; /reset is specified to restore flag state, + // not to bring a stopped backend back up. + if (this.backendMaybeDown || this.resetSupported === false) { + await this.start(); + this.backendMaybeDown = false; + return; + } + + const status = await this.call('/reset'); + + if (status === 404 || status === 501) { + // The documented fallback. Remembered so the probe happens once per suite. + this.resetSupported = false; + await this.start(); + return; + } + + if (status < 200 || status >= 300) { + throw new Error(`POST /reset on ${this.base()} returned ${status}`); + } + + this.resetSupported = true; + } + + async changeFlag(): Promise { + await this.require('/change'); + } + + /** + * Makes the backend unreachable without touching any container: the backend process inside the + * still-running container is stopped. + * + * See the class documentation for why that distinction is a requirement rather than a preference. + */ + async disconnect(): Promise { + this.backendMaybeDown = true; + await this.require('/stop'); + } + + /** + * Starts the backend again with the configuration already in effect. + * + * That restores the same baseline flag state, so the provider observes a change in availability + * and never a change in flag values. + */ + async reconnect(): Promise { + await this.start(); + this.backendMaybeDown = false; + } + + private async start(): Promise { + await this.require('/start', { config: this.configuration }); + } + + /** Performs a control call and fails on any non-2xx response. */ + private async require(path: string, query?: Record): Promise { + const status = await this.call(path, query); + if (status < 200 || status >= 300) { + throw new Error(`POST ${path} on ${this.base()} returned ${status}`); + } + } + + /** Performs one control-API request and returns its status code. */ + private async call(path: string, query?: Record): Promise { + const search = query ? `?${new URLSearchParams(query).toString()}` : ''; + const target = `${this.base()}${path}${search}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); + + try { + const response = await fetch(target, { method: 'POST', signal: controller.signal }); + + // The response body is drained and discarded: the control API's bodies are human-readable + // messages the TCK is specified never to interpret, and draining releases the connection. It + // cannot throw, so it never reaches the catch below. + await response.arrayBuffer().catch(() => undefined); + + return response.status; + } catch (error) { + throw new Error( + `control request POST ${target} failed: ${(error as Error).message}. The control API must ` + + `stay reachable even while the backend is deliberately down, otherwise an outage cannot ` + + `be ended`, + ); + } finally { + clearTimeout(timer); + } + } + + /** Resolves, validates and caches the base URL. */ + private base(): string { + if (this.baseUrl !== undefined) { + return this.baseUrl; + } + + const raw = this.resolveBaseUrl(); + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error( + `HttpControlOptions.baseUrl '${raw}' is not an absolute URL. The control API address must ` + + `include a scheme, for example http://localhost:32768`, + ); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + // "localhost:32768" parses cleanly, as a scheme of 'localhost:', so this is where a container + // helper's "host:port" is actually caught rather than by the parse above. + throw new Error( + `HttpControlOptions.baseUrl '${raw}' must use http or https, not '${parsed.protocol}'. A ` + + `container helper that returns "host:port" needs a scheme prepended, for example ` + + `http://localhost:32768`, + ); + } + + this.baseUrl = raw.replace(/\/+$/, ''); + return this.baseUrl; + } +}