From 93eaca98df425534c13b51e97efc11e7e33bdf50 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 8 Oct 2026 09:08:20 +0200 Subject: [PATCH 1/2] fix(docs): surface a provider-backed service's scale over its control channel A provider's relay is a singleton network stand-in, never itself a replica: reconcileService's provider branch always plans exactly one OpRunProvider node, regardless of the scale requested through `compose scale` or `scale:`. That value is neither rejected nor acted on by Compose -- it is already passed through unmodified as part of the full service config a provider receives over its control channel (GetServiceConfigType marshals the whole resolved ServiceConfig). Interpreting it -- scaling the real resource the provider manages, ignoring it, or rejecting it itself -- is entirely the provider's own call. The example provider used by the e2e suite is extended to read that response into the real types.ServiceConfig (the same struct Compose marshaled) and call GetScale() on it -- the canonical accessor, which already reconciles the scale: and deploy.replicas: spellings -- then surfaces the result as CONFIG_SCALE, exercising the path end to end the same way it already does for CONFIG_TYPE. Two regression tests: - TestReconcileService_ProviderScaleNeverMultipliesTheRelay locks the plan-level invariant: a declared scale of 3 still produces exactly one OpRunProvider node. - TestProviderScaleReachesTheProvider (e2e) locks the value actually reaching the provider through a real daemon, that `compose scale` itself converges without error on a provider-backed service, and that the relay stays a single container across two different scale requests. Verified: go build, go test ./pkg/compose/..., and golangci-lint all clean; the full provider e2e subset re-run clean against a locally built binary. Signed-off-by: Nicolas De Loof --- docs/examples/provider.go | 18 +++++++++++++----- pkg/compose/reconcile.go | 10 ++++++++++ pkg/compose/reconcile_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/examples/provider.go b/docs/examples/provider.go index 59f939e3bbc..37b0cfd9ced 100644 --- a/docs/examples/provider.go +++ b/docs/examples/provider.go @@ -30,6 +30,7 @@ import ( "strings" "time" + "github.com/compose-spec/compose-go/v2/types" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -223,11 +224,16 @@ func up(options options, args []string) { // buffered bytes and hang on the next answer. responses := json.NewDecoder(os.Stdin) fmt.Printf(`{ "type": "get-service-config" }%s`, lineSeparator) - var config struct { - Provider struct { - Type string `json:"type"` - } `json:"provider"` - } + // Decoding into the real types.ServiceConfig (the same struct Compose + // marshaled) means GetScale() is available: Compose never rejects or + // interprets a scale request on a provider-backed service -- the relay + // it deploys is always a single container regardless -- the value is + // passed through unmodified here instead, for the provider itself to + // act on (or ignore) for the real resource it manages. GetScale() is + // the canonical way to read it, already handling both the scale: and + // deploy.replicas: spellings; reading either field directly would miss + // whichever one the compose file didn't use. + var config types.ServiceConfig if err := responses.Decode(&config); err != nil { // error text is not JSON-safe either: encode, don't interpolate msg, _ := json.Marshal(map[string]string{"type": "error", "message": fmt.Sprintf("get-service-config failed: %v", err)}) @@ -238,6 +244,8 @@ func up(options options, args []string) { // encode the message instead of interpolating it into a JSON literal setenv, _ := json.Marshal(map[string]string{"type": "setenv", "message": "CONFIG_TYPE=" + config.Provider.Type}) fmt.Println(string(setenv)) + scaleEnv, _ := json.Marshal(map[string]string{"type": "setenv", "message": fmt.Sprintf("CONFIG_SCALE=%d", config.GetScale())}) + fmt.Println(string(scaleEnv)) // When asked to, stand up a real endpoint on the host and publish it, so // compose deploys a relay and consumers reach it as http://:80. diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index cb2b805b21b..ab135310b88 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -724,6 +724,16 @@ func (r *reconciler) reconcileService(service types.ServiceConfig) error { return nil } if service.Provider != nil { + // A provider's relay is a singleton network stand-in, never itself a + // replica: this branch always plans exactly one OpRunProvider node, + // regardless of service.GetScale(). scale/deploy.replicas is NOT + // rejected or otherwise special-cased here -- it is deliberately + // passed through unmodified as part of the service config the + // provider receives over its control channel (GetServiceConfigType + // in plugins.go marshals the whole resolved ServiceConfig, Scale + // included), so interpreting it -- scaling the real + // resource the provider manages, ignoring it, or rejecting it itself + // -- is entirely the provider's call, never Compose's. serviceCopy := service deps := r.infrastructureDeps(service) // A service migrated from replicas to a provider leaves its old diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index 38477fc27db..4dd204acd44 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -260,6 +260,33 @@ func TestReconcileService_ProviderRelayLeftAlone(t *testing.T) { `)+"\n") } +// A provider's relay is a singleton network stand-in, never itself a +// replica: `compose scale`/`deploy.replicas` on a provider-backed service +// must still plan exactly one OpRunProvider node, whatever count was +// requested -- the value is neither rejected nor acted on by Compose, it is +// passed through unmodified to the provider over its control channel +// (GetServiceConfigType marshals the whole resolved ServiceConfig), which +// decides what to do with it for the resource it actually manages. +func TestReconcileService_ProviderScaleNeverMultipliesTheRelay(t *testing.T) { + scale := 3 + db := types.ServiceConfig{ + Name: "db", + Provider: &types.ServiceProviderConfig{Type: "test"}, + Scale: &scale, + } + project := &types.Project{ + Name: "myproject", + Services: types.Services{"db": db}, + } + observed := emptyObserved() + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt) + assert.NilError(t, err) + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 provider:db, RunProvider, provider service +`)+"\n") +} + // TestReconcileNetworks_DivergedAlsoRecreatesChangedContainer verifies the // entangled case: when a container attached to a diverged network also has its // own config changed, it is both reconnected (by the network recreate) and From 1773c80518d28f7c8f9131ff9f62636ee877d8e8 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 8 Oct 2026 09:08:58 +0200 Subject: [PATCH 2/2] fix(e2e): verify scale propagation and relay singularity against real state A Copilot review finding on TestProviderScaleReachesTheProvider: the scenario left both central guarantees unobserved -- the second `compose scale` call only checked a successful exit, and no relay container ever existed to assert singularity against (PROVIDER_DEMO_ENDPOINT was unset). It would have passed even if a later scale silently lost the requested value, or if relay creation mishandled it. The scenario now enables endpoint publication (as in TestProviderPublishEndpoint) so a real relay container exists, includes the dependent service in each scale call's targets so it is (re)created in the same call, and asserts against real state per the project's own e2e conventions (pkg/e2e/SCENARIO.md: state-based checks over output matching) -- ContainerEnv for the exact value the provider received, ServiceScale("db", 1) for the relay staying a single container across two different scale requests. Verified: the full provider e2e subset re-run clean against a locally built binary, golangci-lint clean. Signed-off-by: Nicolas De Loof --- pkg/e2e/providers_test.go | 24 +++++++++++++++++++ .../compose.yaml | 13 ++++++++++ 2 files changed, 37 insertions(+) create mode 100644 pkg/e2e/testdata/TestProviderScaleReachesTheProvider/compose.yaml diff --git a/pkg/e2e/providers_test.go b/pkg/e2e/providers_test.go index 672c156f5b7..f6e9044c5a7 100644 --- a/pkg/e2e/providers_test.go +++ b/pkg/e2e/providers_test.go @@ -145,6 +145,30 @@ func TestProviderMigration(t *testing.T) { OutputContains("test-1 | hello from provider")) } +// TestProviderScaleReachesTheProvider locks that `compose scale` on a +// provider-backed service neither fails nor multiplies the relay: the +// requested count is passed through unmodified to the provider over its +// control channel (get-service-config), which is free to act on it for the +// real resource it manages, while Compose's own relay stays a single +// container regardless. PROVIDER_DEMO_ENDPOINT publishes a real endpoint so +// a relay container actually exists to assert singularity against; test is +// included in the scale targets so it gets created in the same call and its +// env records the exact value the provider received. +func TestProviderScaleReachesTheProvider(t *testing.T) { + relayImage := "compose-relay-e2e" + s := providerScenario(t, "scale on a provider-backed service must reach the provider, not be rejected or applied to the relay") + s.CLI().RunCmd(t, "docker", "build", "-t", relayImage, "../../relay") + s.Env("PROVIDER_DEMO_ENDPOINT=1", "COMPOSE_RELAY_IMAGE="+relayImage) + s.Step("scale converges without error and the dependent sees the exact value the provider received", + ComposeCmd("scale", "db=5", "test=1"), + ContainerEnv("test", "DB_CONFIG_SCALE", "5"), + ServiceScale("db", 1)). + Step("a second scale updates the value without ever multiplying the relay", + ComposeCmd("scale", "db=7", "test=1"), + ContainerEnv("test", "DB_CONFIG_SCALE", "7"), + ServiceScale("db", 1)) +} + func TestProviderPublishEndpoint(t *testing.T) { // The example provider stands up a real endpoint on the host and // publishes it; compose deploys a relay under the service's name, so the diff --git a/pkg/e2e/testdata/TestProviderScaleReachesTheProvider/compose.yaml b/pkg/e2e/testdata/TestProviderScaleReachesTheProvider/compose.yaml new file mode 100644 index 00000000000..5c5ff221e49 --- /dev/null +++ b/pkg/e2e/testdata/TestProviderScaleReachesTheProvider/compose.yaml @@ -0,0 +1,13 @@ +services: + test: + image: alpine + command: env + depends_on: + - db + db: + provider: + type: example-provider + options: + name: db + type: test1 + size: 1