mirror of
https://github.com/docker/compose.git
synced 2026-07-11 10:44:06 +00:00
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:
parent
8e0d5e17a7
commit
672dc14d29
7 changed files with 235 additions and 14 deletions
|
|
@ -74,8 +74,14 @@ func composeCommand() *cobra.Command {
|
||||||
downCmd.Flags().String("name", "", "Name of the database to be deleted")
|
downCmd.Flags().String("name", "", "Name of the database to be deleted")
|
||||||
_ = downCmd.MarkFlagRequired("name")
|
_ = downCmd.MarkFlagRequired("name")
|
||||||
|
|
||||||
c.AddCommand(upCmd, downCmd)
|
stopCmd := &cobra.Command{
|
||||||
c.AddCommand(metadataCommand(upCmd, downCmd))
|
Use: "stop",
|
||||||
|
Run: stop,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AddCommand(upCmd, downCmd, stopCmd)
|
||||||
|
c.AddCommand(metadataCommand(upCmd, downCmd, stopCmd))
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,21 +102,29 @@ func down(_ *cobra.Command, _ []string) {
|
||||||
fmt.Printf(`{ "type": "error", "message": "Permission error" }%s`, lineSeparator)
|
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{
|
return &cobra.Command{
|
||||||
Use: "metadata",
|
Use: "metadata",
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
metadata(upCmd, downCmd)
|
metadata(upCmd, downCmd, stopCmd)
|
||||||
},
|
},
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func metadata(upCmd, downCmd *cobra.Command) {
|
func metadata(upCmd, downCmd, stopCmd *cobra.Command) {
|
||||||
metadata := ProviderMetadata{}
|
metadata := ProviderMetadata{}
|
||||||
metadata.Description = "Manage services on AwesomeCloud"
|
metadata.Description = "Manage services on AwesomeCloud"
|
||||||
metadata.Up = commandParameters(upCmd)
|
metadata.Up = commandParameters(upCmd)
|
||||||
metadata.Down = commandParameters(downCmd)
|
metadata.Down = commandParameters(downCmd)
|
||||||
|
stopParams := commandParameters(stopCmd)
|
||||||
|
metadata.Stop = &stopParams
|
||||||
jsonMetadata, err := json.Marshal(metadata)
|
jsonMetadata, err := json.Marshal(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|
@ -134,9 +148,10 @@ func commandParameters(cmd *cobra.Command) CommandMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderMetadata struct {
|
type ProviderMetadata struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Up CommandMetadata `json:"up"`
|
Up CommandMetadata `json:"up"`
|
||||||
Down CommandMetadata `json:"down"`
|
Down CommandMetadata `json:"down"`
|
||||||
|
Stop *CommandMetadata `json:"stop,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandMetadata struct {
|
type CommandMetadata struct {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
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
|
## 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.
|
`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.
|
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
|
## 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.
|
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"
|
"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
|
- `description`: Human-readable description of the provider
|
||||||
- `up`: Object describing the parameters accepted by the `up` command
|
- `up`: Object describing the parameters accepted by the `up` command
|
||||||
- `down`: Object describing the parameters accepted by the `down` 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:
|
And for each command parameter, you should include the following properties:
|
||||||
- `name`: The parameter name (without `--` prefix)
|
- `name`: The parameter name (without `--` prefix)
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if command == "stop" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
mux.Lock()
|
mux.Lock()
|
||||||
defer mux.Unlock()
|
defer mux.Unlock()
|
||||||
for name, s := range project.Services {
|
for name, s := range project.Services {
|
||||||
|
|
@ -86,7 +90,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
|
||||||
return nil
|
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
|
var action string
|
||||||
switch command {
|
switch command {
|
||||||
case "up":
|
case "up":
|
||||||
|
|
@ -95,6 +99,9 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
|
||||||
case "down":
|
case "down":
|
||||||
s.events.On(removingEvent(service.Name))
|
s.events.On(removingEvent(service.Name))
|
||||||
action = "remove"
|
action = "remove"
|
||||||
|
case "stop":
|
||||||
|
s.events.On(stoppingEvent(service.Name))
|
||||||
|
action = "stop"
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported plugin command: %s", command)
|
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))
|
s.events.On(createdEvent(service.Name))
|
||||||
case "down":
|
case "down":
|
||||||
s.events.On(removedEvent(service.Name))
|
s.events.On(removedEvent(service.Name))
|
||||||
|
case "stop":
|
||||||
|
s.events.On(stoppedEvent(service.Name))
|
||||||
}
|
}
|
||||||
return variables, nil
|
return variables, nil
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +187,10 @@ func (s *composeService) setupPluginCommand(ctx context.Context, project *types.
|
||||||
currentCommandMetadata = cmdOptionsMetadata.Up
|
currentCommandMetadata = cmdOptionsMetadata.Up
|
||||||
case "down":
|
case "down":
|
||||||
currentCommandMetadata = cmdOptionsMetadata.Down
|
currentCommandMetadata = cmdOptionsMetadata.Down
|
||||||
|
case "stop":
|
||||||
|
if cmdOptionsMetadata.Stop != nil {
|
||||||
|
currentCommandMetadata = *cmdOptionsMetadata.Stop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := *service.Provider
|
provider := *service.Provider
|
||||||
|
|
@ -241,13 +254,14 @@ func (s *composeService) getPluginMetadata(path, command string, project *types.
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderMetadata struct {
|
type ProviderMetadata struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Up CommandMetadata `json:"up"`
|
Up CommandMetadata `json:"up"`
|
||||||
Down CommandMetadata `json:"down"`
|
Down CommandMetadata `json:"down"`
|
||||||
|
Stop *CommandMetadata `json:"stop,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p ProviderMetadata) IsEmpty() bool {
|
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 {
|
type CommandMetadata struct {
|
||||||
|
|
|
||||||
112
pkg/compose/plugins_test.go
Normal file
112
pkg/compose/plugins_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,17 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
serv := project.Services[service]
|
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)
|
return s.stopContainers(ctx, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
pkg/e2e/fixtures/providers/provider-stop.yaml
Normal file
13
pkg/e2e/fixtures/providers/provider-stop.yaml
Normal 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
|
||||||
|
|
@ -29,6 +29,37 @@ import (
|
||||||
"gotest.tools/v3/icmd"
|
"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) {
|
func TestDependsOnMultipleProviders(t *testing.T) {
|
||||||
provider, err := findExecutable("example-provider")
|
provider, err := findExecutable("example-provider")
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue