feat: add stop lifecycle hook for external providers

Provider-backed services were silently skipped on `docker compose stop`,
leaving external resources running after the user expected the stack to
be paused (e.g. after Ctrl+C during `up --watch`).

Compose now invokes `<provider> compose stop <service>` for providers
that advertise a `stop` block in their `metadata` subcommand output.
Providers that do not advertise stop (or do not implement metadata at
all) are silently skipped, preserving backward compatibility with
existing providers that pre-date this hook.

Closes #13772

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-05-07 18:38:41 +02:00 committed by Guillaume Lours
parent 8e0d5e17a7
commit 672dc14d29
7 changed files with 235 additions and 14 deletions

View file

@ -74,8 +74,14 @@ func composeCommand() *cobra.Command {
downCmd.Flags().String("name", "", "Name of the database to be deleted")
_ = downCmd.MarkFlagRequired("name")
c.AddCommand(upCmd, downCmd)
c.AddCommand(metadataCommand(upCmd, downCmd))
stopCmd := &cobra.Command{
Use: "stop",
Run: stop,
Args: cobra.ExactArgs(1),
}
c.AddCommand(upCmd, downCmd, stopCmd)
c.AddCommand(metadataCommand(upCmd, downCmd, stopCmd))
return c
}
@ -96,21 +102,29 @@ func down(_ *cobra.Command, _ []string) {
fmt.Printf(`{ "type": "error", "message": "Permission error" }%s`, lineSeparator)
}
func metadataCommand(upCmd, downCmd *cobra.Command) *cobra.Command {
func stop(_ *cobra.Command, _ []string) {
if marker := os.Getenv("PROVIDER_STOP_MARKER"); marker != "" {
_ = os.WriteFile(marker, []byte("stopped"), 0o600)
}
}
func metadataCommand(upCmd, downCmd, stopCmd *cobra.Command) *cobra.Command {
return &cobra.Command{
Use: "metadata",
Run: func(cmd *cobra.Command, _ []string) {
metadata(upCmd, downCmd)
metadata(upCmd, downCmd, stopCmd)
},
Args: cobra.NoArgs,
}
}
func metadata(upCmd, downCmd *cobra.Command) {
func metadata(upCmd, downCmd, stopCmd *cobra.Command) {
metadata := ProviderMetadata{}
metadata.Description = "Manage services on AwesomeCloud"
metadata.Up = commandParameters(upCmd)
metadata.Down = commandParameters(downCmd)
stopParams := commandParameters(stopCmd)
metadata.Stop = &stopParams
jsonMetadata, err := json.Marshal(metadata)
if err != nil {
panic(err)
@ -134,9 +148,10 @@ func commandParameters(cmd *cobra.Command) CommandMetadata {
}
type ProviderMetadata struct {
Description string `json:"description"`
Up CommandMetadata `json:"up"`
Down CommandMetadata `json:"down"`
Description string `json:"description"`
Up CommandMetadata `json:"up"`
Down CommandMetadata `json:"down"`
Stop *CommandMetadata `json:"stop,omitempty"`
}
type CommandMetadata struct {

View file

@ -30,7 +30,7 @@ the resource(s) needed to run a service.
If `provider.type` doesn't resolve into any of those, Compose will report an error and interrupt the `up` command.
To be a valid Compose extension, provider command *MUST* accept a `compose` command (which can be hidden)
with subcommands `up` and `down`.
with subcommands `up` and `down`. It *MAY* additionally implement a `stop` subcommand to support `docker compose stop`.
## Up lifecycle
@ -107,6 +107,20 @@ into its runtime environment.
`down` lifecycle is equivalent to `up` with the `<provider> compose --project-name <NAME> down <SERVICE>` command.
The provider is responsible for releasing all resources associated with the service.
## Stop lifecycle
When the user runs `docker compose stop`, Compose invokes `<provider> compose --project-name <NAME> stop <SERVICE>` for each
provider-backed service in reverse dependency order. The provider should pause the resource without releasing it, so a later
`docker compose start` or `docker compose up` can resume it. Any `setenv` JSON message returned during `stop` is ignored,
since dependent services are also stopping.
The `stop` hook is opt-in: Compose invokes it only when the provider declares a `stop` block in its `metadata` subcommand
output. Providers that do not advertise `stop` in metadata (or do not implement the `metadata` subcommand at all) are
silently skipped during `docker compose stop`, preserving backward compatibility with providers that pre-date this hook.
The `--timeout` flag of `docker compose stop` applies only to container services; provider stop hooks are not subject to
this timeout and are responsible for managing their own shutdown duration.
## Provide metadata about options
Compose extensions *MAY* optionally implement a `metadata` subcommand to provide information about the parameters accepted by the `up` and `down` commands.
@ -153,6 +167,16 @@ The expected JSON output format is:
"type": "string"
}
]
},
"stop": {
"parameters": [
{
"name": "name",
"description": "Name of the database to be stopped",
"required": true,
"type": "string"
}
]
}
}
```
@ -160,6 +184,7 @@ The top elements are:
- `description`: Human-readable description of the provider
- `up`: Object describing the parameters accepted by the `up` command
- `down`: Object describing the parameters accepted by the `down` command
- `stop`: Object describing the parameters accepted by the `stop` command (optional)
And for each command parameter, you should include the following properties:
- `name`: The parameter name (without `--` prefix)

View file

@ -72,6 +72,10 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
return err
}
if command == "stop" {
return nil
}
mux.Lock()
defer mux.Unlock()
for name, s := range project.Services {
@ -86,7 +90,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
return nil
}
func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) {
func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) { //nolint:gocyclo
var action string
switch command {
case "up":
@ -95,6 +99,9 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
case "down":
s.events.On(removingEvent(service.Name))
action = "remove"
case "stop":
s.events.On(stoppingEvent(service.Name))
action = "stop"
default:
return nil, fmt.Errorf("unsupported plugin command: %s", command)
}
@ -152,6 +159,8 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
s.events.On(createdEvent(service.Name))
case "down":
s.events.On(removedEvent(service.Name))
case "stop":
s.events.On(stoppedEvent(service.Name))
}
return variables, nil
}
@ -178,6 +187,10 @@ func (s *composeService) setupPluginCommand(ctx context.Context, project *types.
currentCommandMetadata = cmdOptionsMetadata.Up
case "down":
currentCommandMetadata = cmdOptionsMetadata.Down
case "stop":
if cmdOptionsMetadata.Stop != nil {
currentCommandMetadata = *cmdOptionsMetadata.Stop
}
}
provider := *service.Provider
@ -241,13 +254,14 @@ func (s *composeService) getPluginMetadata(path, command string, project *types.
}
type ProviderMetadata struct {
Description string `json:"description"`
Up CommandMetadata `json:"up"`
Down CommandMetadata `json:"down"`
Description string `json:"description"`
Up CommandMetadata `json:"up"`
Down CommandMetadata `json:"down"`
Stop *CommandMetadata `json:"stop,omitempty"`
}
func (p ProviderMetadata) IsEmpty() bool {
return p.Description == "" && p.Up.Parameters == nil && p.Down.Parameters == nil
return p.Description == "" && p.Up.Parameters == nil && p.Down.Parameters == nil && p.Stop == nil
}
type CommandMetadata struct {

112
pkg/compose/plugins_test.go Normal file
View file

@ -0,0 +1,112 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package compose
import (
"encoding/json"
"testing"
"gotest.tools/v3/assert"
)
func TestProviderMetadata_IsEmpty(t *testing.T) {
param := []ParameterMetadata{{Name: "x"}}
tests := []struct {
name string
metadata ProviderMetadata
want bool
}{
{
name: "empty metadata",
metadata: ProviderMetadata{},
want: true,
},
{
name: "only Description set",
metadata: ProviderMetadata{Description: "something"},
want: false,
},
{
name: "only Up.Parameters set",
metadata: ProviderMetadata{Up: CommandMetadata{Parameters: param}},
want: false,
},
{
name: "only Down.Parameters set",
metadata: ProviderMetadata{Down: CommandMetadata{Parameters: param}},
want: false,
},
{
name: "only Stop set",
metadata: ProviderMetadata{Stop: &CommandMetadata{Parameters: param}},
want: false,
},
{
name: "Stop set with empty parameters",
metadata: ProviderMetadata{Stop: &CommandMetadata{}},
want: false,
},
{
name: "all fields set",
metadata: ProviderMetadata{
Description: "full",
Up: CommandMetadata{Parameters: param},
Down: CommandMetadata{Parameters: param},
Stop: &CommandMetadata{Parameters: param},
},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.metadata.IsEmpty(), tc.want)
})
}
}
func TestProviderMetadata_JSONUnmarshal(t *testing.T) {
raw := `{"description":"x","up":{"parameters":[{"name":"a"}]},"down":{"parameters":[{"name":"b"}]},"stop":{"parameters":[{"name":"c"}]}}`
var metadata ProviderMetadata
err := json.Unmarshal([]byte(raw), &metadata)
assert.NilError(t, err)
assert.Equal(t, metadata.Description, "x")
assert.Equal(t, metadata.Up.Parameters[0].Name, "a")
assert.Equal(t, metadata.Down.Parameters[0].Name, "b")
assert.Assert(t, metadata.Stop != nil, "Stop should be non-nil when present in JSON")
assert.Equal(t, metadata.Stop.Parameters[0].Name, "c")
}
func TestProviderMetadata_StopAbsent(t *testing.T) {
raw := `{"description":"x","up":{"parameters":[]},"down":{"parameters":[]}}`
var metadata ProviderMetadata
err := json.Unmarshal([]byte(raw), &metadata)
assert.NilError(t, err)
assert.Assert(t, metadata.Stop == nil, "Stop should be nil when absent from JSON")
}
func TestProviderMetadata_StopAdvertisedWithoutParameters(t *testing.T) {
raw := `{"stop":{"parameters":null}}`
var metadata ProviderMetadata
err := json.Unmarshal([]byte(raw), &metadata)
assert.NilError(t, err)
assert.Assert(t, metadata.Stop != nil, "Stop should be non-nil when key present even with null parameters")
}

View file

@ -53,6 +53,17 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a
return nil
}
serv := project.Services[service]
if serv.Provider != nil {
path, err := s.getPluginBinaryPath(serv.Provider.Type)
if err != nil {
return err
}
metadata := s.getPluginMetadata(path, serv.Provider.Type, project)
if metadata.Stop == nil {
return nil
}
return s.runPlugin(ctx, project, serv, "stop")
}
return s.stopContainers(ctx, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event)
})
}

View file

@ -0,0 +1,13 @@
services:
test:
image: alpine
command: echo ok
depends_on:
- provider
provider:
provider:
type: example-provider
options:
name: provider
type: test
size: 1

View file

@ -29,6 +29,37 @@ import (
"gotest.tools/v3/icmd"
)
// TestProviderStopHook verifies that "docker compose stop" invokes the provider
// binary's "stop" subcommand. The example provider writes a sentinel file at
// PROVIDER_STOP_MARKER when its stop subcommand runs.
func TestProviderStopHook(t *testing.T) {
provider, err := findExecutable("example-provider")
assert.NilError(t, err)
markerFile := filepath.Join(t.TempDir(), "example-provider-stop-marker")
path := fmt.Sprintf("%s%s%s", os.Getenv("PATH"), string(os.PathListSeparator), filepath.Dir(provider))
c := NewParallelCLI(t, WithEnv(
"PATH="+path,
"PROVIDER_STOP_MARKER="+markerFile,
))
const projectName = "provider-stop-hook"
t.Cleanup(func() {
_ = os.Remove(markerFile)
c.cleanupWithDown(t, projectName)
})
res := c.RunDockerComposeCmd(t, "-f", "fixtures/providers/provider-stop.yaml", "--project-name", projectName, "up", "-d")
res.Assert(t, icmd.Success)
res = c.RunDockerComposeCmd(t, "-f", "fixtures/providers/provider-stop.yaml", "--project-name", projectName, "stop")
res.Assert(t, icmd.Success)
_, statErr := os.Stat(markerFile)
assert.NilError(t, statErr, "expected example-provider stop subcommand to write marker file %q", markerFile)
}
func TestDependsOnMultipleProviders(t *testing.T) {
provider, err := findExecutable("example-provider")
assert.NilError(t, err)