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 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