Skip to content

Commit 672dc14

Browse files
committed
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>
1 parent 8e0d5e1 commit 672dc14

7 files changed

Lines changed: 235 additions & 14 deletions

File tree

docs/examples/provider.go

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,14 @@ func composeCommand() *cobra.Command {
7474
downCmd.Flags().String("name", "", "Name of the database to be deleted")
7575
_ = downCmd.MarkFlagRequired("name")
7676

77-
c.AddCommand(upCmd, downCmd)
78-
c.AddCommand(metadataCommand(upCmd, downCmd))
77+
stopCmd := &cobra.Command{
78+
Use: "stop",
79+
Run: stop,
80+
Args: cobra.ExactArgs(1),
81+
}
82+
83+
c.AddCommand(upCmd, downCmd, stopCmd)
84+
c.AddCommand(metadataCommand(upCmd, downCmd, stopCmd))
7985
return c
8086
}
8187

@@ -96,21 +102,29 @@ func down(_ *cobra.Command, _ []string) {
96102
fmt.Printf(`{ "type": "error", "message": "Permission error" }%s`, lineSeparator)
97103
}
98104

99-
func metadataCommand(upCmd, downCmd *cobra.Command) *cobra.Command {
105+
func stop(_ *cobra.Command, _ []string) {
106+
if marker := os.Getenv("PROVIDER_STOP_MARKER"); marker != "" {
107+
_ = os.WriteFile(marker, []byte("stopped"), 0o600)
108+
}
109+
}
110+
111+
func metadataCommand(upCmd, downCmd, stopCmd *cobra.Command) *cobra.Command {
100112
return &cobra.Command{
101113
Use: "metadata",
102114
Run: func(cmd *cobra.Command, _ []string) {
103-
metadata(upCmd, downCmd)
115+
metadata(upCmd, downCmd, stopCmd)
104116
},
105117
Args: cobra.NoArgs,
106118
}
107119
}
108120

109-
func metadata(upCmd, downCmd *cobra.Command) {
121+
func metadata(upCmd, downCmd, stopCmd *cobra.Command) {
110122
metadata := ProviderMetadata{}
111123
metadata.Description = "Manage services on AwesomeCloud"
112124
metadata.Up = commandParameters(upCmd)
113125
metadata.Down = commandParameters(downCmd)
126+
stopParams := commandParameters(stopCmd)
127+
metadata.Stop = &stopParams
114128
jsonMetadata, err := json.Marshal(metadata)
115129
if err != nil {
116130
panic(err)
@@ -134,9 +148,10 @@ func commandParameters(cmd *cobra.Command) CommandMetadata {
134148
}
135149

136150
type ProviderMetadata struct {
137-
Description string `json:"description"`
138-
Up CommandMetadata `json:"up"`
139-
Down CommandMetadata `json:"down"`
151+
Description string `json:"description"`
152+
Up CommandMetadata `json:"up"`
153+
Down CommandMetadata `json:"down"`
154+
Stop *CommandMetadata `json:"stop,omitempty"`
140155
}
141156

142157
type CommandMetadata struct {

docs/extension.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ the resource(s) needed to run a service.
3030
If `provider.type` doesn't resolve into any of those, Compose will report an error and interrupt the `up` command.
3131

3232
To be a valid Compose extension, provider command *MUST* accept a `compose` command (which can be hidden)
33-
with subcommands `up` and `down`.
33+
with subcommands `up` and `down`. It *MAY* additionally implement a `stop` subcommand to support `docker compose stop`.
3434

3535
## Up lifecycle
3636

@@ -107,6 +107,20 @@ into its runtime environment.
107107
`down` lifecycle is equivalent to `up` with the `<provider> compose --project-name <NAME> down <SERVICE>` command.
108108
The provider is responsible for releasing all resources associated with the service.
109109

110+
## Stop lifecycle
111+
112+
When the user runs `docker compose stop`, Compose invokes `<provider> compose --project-name <NAME> stop <SERVICE>` for each
113+
provider-backed service in reverse dependency order. The provider should pause the resource without releasing it, so a later
114+
`docker compose start` or `docker compose up` can resume it. Any `setenv` JSON message returned during `stop` is ignored,
115+
since dependent services are also stopping.
116+
117+
The `stop` hook is opt-in: Compose invokes it only when the provider declares a `stop` block in its `metadata` subcommand
118+
output. Providers that do not advertise `stop` in metadata (or do not implement the `metadata` subcommand at all) are
119+
silently skipped during `docker compose stop`, preserving backward compatibility with providers that pre-date this hook.
120+
121+
The `--timeout` flag of `docker compose stop` applies only to container services; provider stop hooks are not subject to
122+
this timeout and are responsible for managing their own shutdown duration.
123+
110124
## Provide metadata about options
111125

112126
Compose extensions *MAY* optionally implement a `metadata` subcommand to provide information about the parameters accepted by the `up` and `down` commands.
@@ -153,13 +167,24 @@ The expected JSON output format is:
153167
"type": "string"
154168
}
155169
]
170+
},
171+
"stop": {
172+
"parameters": [
173+
{
174+
"name": "name",
175+
"description": "Name of the database to be stopped",
176+
"required": true,
177+
"type": "string"
178+
}
179+
]
156180
}
157181
}
158182
```
159183
The top elements are:
160184
- `description`: Human-readable description of the provider
161185
- `up`: Object describing the parameters accepted by the `up` command
162186
- `down`: Object describing the parameters accepted by the `down` command
187+
- `stop`: Object describing the parameters accepted by the `stop` command (optional)
163188

164189
And for each command parameter, you should include the following properties:
165190
- `name`: The parameter name (without `--` prefix)

pkg/compose/plugins.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
7272
return err
7373
}
7474

75+
if command == "stop" {
76+
return nil
77+
}
78+
7579
mux.Lock()
7680
defer mux.Unlock()
7781
for name, s := range project.Services {
@@ -86,7 +90,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
8690
return nil
8791
}
8892

89-
func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) {
93+
func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) { //nolint:gocyclo
9094
var action string
9195
switch command {
9296
case "up":
@@ -95,6 +99,9 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
9599
case "down":
96100
s.events.On(removingEvent(service.Name))
97101
action = "remove"
102+
case "stop":
103+
s.events.On(stoppingEvent(service.Name))
104+
action = "stop"
98105
default:
99106
return nil, fmt.Errorf("unsupported plugin command: %s", command)
100107
}
@@ -152,6 +159,8 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
152159
s.events.On(createdEvent(service.Name))
153160
case "down":
154161
s.events.On(removedEvent(service.Name))
162+
case "stop":
163+
s.events.On(stoppedEvent(service.Name))
155164
}
156165
return variables, nil
157166
}
@@ -178,6 +187,10 @@ func (s *composeService) setupPluginCommand(ctx context.Context, project *types.
178187
currentCommandMetadata = cmdOptionsMetadata.Up
179188
case "down":
180189
currentCommandMetadata = cmdOptionsMetadata.Down
190+
case "stop":
191+
if cmdOptionsMetadata.Stop != nil {
192+
currentCommandMetadata = *cmdOptionsMetadata.Stop
193+
}
181194
}
182195

183196
provider := *service.Provider
@@ -241,13 +254,14 @@ func (s *composeService) getPluginMetadata(path, command string, project *types.
241254
}
242255

243256
type ProviderMetadata struct {
244-
Description string `json:"description"`
245-
Up CommandMetadata `json:"up"`
246-
Down CommandMetadata `json:"down"`
257+
Description string `json:"description"`
258+
Up CommandMetadata `json:"up"`
259+
Down CommandMetadata `json:"down"`
260+
Stop *CommandMetadata `json:"stop,omitempty"`
247261
}
248262

249263
func (p ProviderMetadata) IsEmpty() bool {
250-
return p.Description == "" && p.Up.Parameters == nil && p.Down.Parameters == nil
264+
return p.Description == "" && p.Up.Parameters == nil && p.Down.Parameters == nil && p.Stop == nil
251265
}
252266

253267
type CommandMetadata struct {

pkg/compose/plugins_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"encoding/json"
21+
"testing"
22+
23+
"gotest.tools/v3/assert"
24+
)
25+
26+
func TestProviderMetadata_IsEmpty(t *testing.T) {
27+
param := []ParameterMetadata{{Name: "x"}}
28+
29+
tests := []struct {
30+
name string
31+
metadata ProviderMetadata
32+
want bool
33+
}{
34+
{
35+
name: "empty metadata",
36+
metadata: ProviderMetadata{},
37+
want: true,
38+
},
39+
{
40+
name: "only Description set",
41+
metadata: ProviderMetadata{Description: "something"},
42+
want: false,
43+
},
44+
{
45+
name: "only Up.Parameters set",
46+
metadata: ProviderMetadata{Up: CommandMetadata{Parameters: param}},
47+
want: false,
48+
},
49+
{
50+
name: "only Down.Parameters set",
51+
metadata: ProviderMetadata{Down: CommandMetadata{Parameters: param}},
52+
want: false,
53+
},
54+
{
55+
name: "only Stop set",
56+
metadata: ProviderMetadata{Stop: &CommandMetadata{Parameters: param}},
57+
want: false,
58+
},
59+
{
60+
name: "Stop set with empty parameters",
61+
metadata: ProviderMetadata{Stop: &CommandMetadata{}},
62+
want: false,
63+
},
64+
{
65+
name: "all fields set",
66+
metadata: ProviderMetadata{
67+
Description: "full",
68+
Up: CommandMetadata{Parameters: param},
69+
Down: CommandMetadata{Parameters: param},
70+
Stop: &CommandMetadata{Parameters: param},
71+
},
72+
want: false,
73+
},
74+
}
75+
76+
for _, tc := range tests {
77+
t.Run(tc.name, func(t *testing.T) {
78+
assert.Equal(t, tc.metadata.IsEmpty(), tc.want)
79+
})
80+
}
81+
}
82+
83+
func TestProviderMetadata_JSONUnmarshal(t *testing.T) {
84+
raw := `{"description":"x","up":{"parameters":[{"name":"a"}]},"down":{"parameters":[{"name":"b"}]},"stop":{"parameters":[{"name":"c"}]}}`
85+
86+
var metadata ProviderMetadata
87+
err := json.Unmarshal([]byte(raw), &metadata)
88+
assert.NilError(t, err)
89+
assert.Equal(t, metadata.Description, "x")
90+
assert.Equal(t, metadata.Up.Parameters[0].Name, "a")
91+
assert.Equal(t, metadata.Down.Parameters[0].Name, "b")
92+
assert.Assert(t, metadata.Stop != nil, "Stop should be non-nil when present in JSON")
93+
assert.Equal(t, metadata.Stop.Parameters[0].Name, "c")
94+
}
95+
96+
func TestProviderMetadata_StopAbsent(t *testing.T) {
97+
raw := `{"description":"x","up":{"parameters":[]},"down":{"parameters":[]}}`
98+
99+
var metadata ProviderMetadata
100+
err := json.Unmarshal([]byte(raw), &metadata)
101+
assert.NilError(t, err)
102+
assert.Assert(t, metadata.Stop == nil, "Stop should be nil when absent from JSON")
103+
}
104+
105+
func TestProviderMetadata_StopAdvertisedWithoutParameters(t *testing.T) {
106+
raw := `{"stop":{"parameters":null}}`
107+
108+
var metadata ProviderMetadata
109+
err := json.Unmarshal([]byte(raw), &metadata)
110+
assert.NilError(t, err)
111+
assert.Assert(t, metadata.Stop != nil, "Stop should be non-nil when key present even with null parameters")
112+
}

pkg/compose/stop.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a
5353
return nil
5454
}
5555
serv := project.Services[service]
56+
if serv.Provider != nil {
57+
path, err := s.getPluginBinaryPath(serv.Provider.Type)
58+
if err != nil {
59+
return err
60+
}
61+
metadata := s.getPluginMetadata(path, serv.Provider.Type, project)
62+
if metadata.Stop == nil {
63+
return nil
64+
}
65+
return s.runPlugin(ctx, project, serv, "stop")
66+
}
5667
return s.stopContainers(ctx, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event)
5768
})
5869
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
services:
2+
test:
3+
image: alpine
4+
command: echo ok
5+
depends_on:
6+
- provider
7+
provider:
8+
provider:
9+
type: example-provider
10+
options:
11+
name: provider
12+
type: test
13+
size: 1

pkg/e2e/providers_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,37 @@ import (
2929
"gotest.tools/v3/icmd"
3030
)
3131

32+
// TestProviderStopHook verifies that "docker compose stop" invokes the provider
33+
// binary's "stop" subcommand. The example provider writes a sentinel file at
34+
// PROVIDER_STOP_MARKER when its stop subcommand runs.
35+
func TestProviderStopHook(t *testing.T) {
36+
provider, err := findExecutable("example-provider")
37+
assert.NilError(t, err)
38+
39+
markerFile := filepath.Join(t.TempDir(), "example-provider-stop-marker")
40+
41+
path := fmt.Sprintf("%s%s%s", os.Getenv("PATH"), string(os.PathListSeparator), filepath.Dir(provider))
42+
c := NewParallelCLI(t, WithEnv(
43+
"PATH="+path,
44+
"PROVIDER_STOP_MARKER="+markerFile,
45+
))
46+
const projectName = "provider-stop-hook"
47+
48+
t.Cleanup(func() {
49+
_ = os.Remove(markerFile)
50+
c.cleanupWithDown(t, projectName)
51+
})
52+
53+
res := c.RunDockerComposeCmd(t, "-f", "fixtures/providers/provider-stop.yaml", "--project-name", projectName, "up", "-d")
54+
res.Assert(t, icmd.Success)
55+
56+
res = c.RunDockerComposeCmd(t, "-f", "fixtures/providers/provider-stop.yaml", "--project-name", projectName, "stop")
57+
res.Assert(t, icmd.Success)
58+
59+
_, statErr := os.Stat(markerFile)
60+
assert.NilError(t, statErr, "expected example-provider stop subcommand to write marker file %q", markerFile)
61+
}
62+
3263
func TestDependsOnMultipleProviders(t *testing.T) {
3364
provider, err := findExecutable("example-provider")
3465
assert.NilError(t, err)

0 commit comments

Comments
 (0)