This commit is contained in:
Guillaume Lours 2026-05-12 11:33:32 +02:00 committed by GitHub
commit fe2bab7bec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 225 additions and 13 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 up` can resume it (note that `docker compose start` only restarts existing containers and does not invoke
provider hooks). 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

@ -66,12 +66,19 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
if err != nil {
return err
}
if cmd == nil {
return nil
}
variables, err := s.executePlugin(cmd, command, service)
if err != nil {
return err
}
if command == "stop" {
return nil
}
mux.Lock()
defer mux.Unlock()
for name, s := range project.Services {
@ -86,7 +93,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 +102,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 +162,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 +190,11 @@ func (s *composeService) setupPluginCommand(ctx context.Context, project *types.
currentCommandMetadata = cmdOptionsMetadata.Up
case "down":
currentCommandMetadata = cmdOptionsMetadata.Down
case "stop":
if cmdOptionsMetadata.Stop == nil {
return nil, nil
}
currentCommandMetadata = *cmdOptionsMetadata.Stop
}
provider := *service.Provider
@ -241,9 +258,10 @@ 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 {

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

@ -0,0 +1,107 @@
/*
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 is empty",
metadata: ProviderMetadata{Stop: &CommandMetadata{}},
want: true,
},
{
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,9 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a
return nil
}
serv := project.Services[service]
if serv.Provider != 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)