Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,98 @@ func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, ser
api.Separator = "_"
}

return options.LoadModel(ctx)
model, err := options.LoadModel(ctx)
if err != nil {
return nil, err
}

// LoadModel returns the raw (partially resolved) model for every service in
// the compose files. When the user selected specific services on the command
// line, restrict the model to those services and their dependencies, mirroring
// how ToProject applies WithSelectedServices on the fully typed project.
if err := filterModelServices(model, services); err != nil {
return nil, err
}

return model, nil
}

// filterModelServices restricts a raw compose model (as returned by LoadModel)
// to the named services and their transitive dependencies, mutating the model
// in place. It is a no-op when no service is selected.
//
// The `config --no-interpolate` and `config --variables` code paths render a
// raw model map rather than a fully typed project, because unresolved
// interpolation variables may prevent a complete project load. That model
// isn't otherwise filtered by the [SERVICE...] argument, so this restores
// parity with the interpolated path by reusing compose-go's typed dependency
// logic (Project.WithSelectedServices) to compute the reachable set.
func filterModelServices(model map[string]any, services []string) error {
if len(services) == 0 {
return nil
}

rawServices, ok := model["services"].(map[string]any)
if !ok {
// No services mapping to select from: surface the same "no such
// service" error the typed path would raise for the first request.
return fmt.Errorf("no such service: %s", services[0])
}

// Build a minimal typed project carrying only service names and their
// depends_on edges, so dependency traversal (and the "no such service"
// validation) is delegated to compose-go rather than reimplemented here.
project := &types.Project{Services: types.Services{}}
for name, raw := range rawServices {
svc, _ := raw.(map[string]any)
project.Services[name] = types.ServiceConfig{
Name: name,
DependsOn: dependsOnFromModel(svc["depends_on"]),
}
}

selected, err := project.WithSelectedServices(services)
if err != nil {
return err
}

for name := range rawServices {
if _, keep := selected.Services[name]; !keep {
delete(rawServices, name)
}
}
return nil
}

// dependsOnFromModel extracts the depends_on edges of a raw service model entry.
// It handles both the short list syntax (`depends_on: [a, b]`) and the long map
// syntax (`depends_on: {a: {condition: ...}}`), since the model may contain
// either form depending on whether normalization ran.
func dependsOnFromModel(v any) types.DependsOnConfig {
switch d := v.(type) {
case []any:
deps := types.DependsOnConfig{}
for _, e := range d {
if name, ok := e.(string); ok {
deps[name] = types.ServiceDependency{Required: true}
}
}
return deps
case map[string]any:
deps := types.DependsOnConfig{}
for name, raw := range d {
dep := types.ServiceDependency{Required: true}
if m, ok := raw.(map[string]any); ok {
if required, ok := m["required"].(bool); ok {
dep.Required = required
}
}
deps[name] = dep
}
return deps
default:
return nil
}
}

// ToProject loads a Compose project using the LoadProject API.
Expand Down
172 changes: 172 additions & 0 deletions cmd/compose/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
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 (
"os"
"path/filepath"
"sort"
"testing"

"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"

"github.com/docker/compose/v5/pkg/mocks"
)

const configFilterCompose = `
name: demo
services:
web:
image: nginx
depends_on:
- db
api:
image: api
depends_on:
db:
condition: service_healthy
db:
image: postgres
lonely:
image: busybox
`

// modelServiceNames returns the sorted service names present in a raw compose model.
func modelServiceNames(t *testing.T, model map[string]any) []string {
t.Helper()
services, ok := model["services"].(map[string]any)
assert.Assert(t, ok, "model has no services mapping")
names := make([]string, 0, len(services))
for name := range services {
names = append(names, name)
}
sort.Strings(names)
return names
}

// TestToModelFiltersSelectedServices is a regression test for
// https://github.com/docker/compose/issues/13614: the [SERVICE...] argument was
// ignored on the `config --no-interpolate` / `config --variables` (raw model)
// code path, so every service was rendered regardless of the selection.
func TestToModelFiltersSelectedServices(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cli := mocks.NewMockCli(ctrl)

dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
assert.NilError(t, os.WriteFile(composePath, []byte(configFilterCompose), 0o600))

for name, noNormalize := range map[string]bool{"normalized": false, "no-normalize": true} {
t.Run(name, func(t *testing.T) {
newOpts := func() *configOptions {
return &configOptions{
noInterpolate: true,
noNormalize: noNormalize,
ProjectOptions: &ProjectOptions{
ConfigPaths: []string{composePath},
ProjectDir: dir,
},
}
}

// A single selected service pulls in its (transitive) dependencies.
model, err := newOpts().ToModel(t.Context(), cli, []string{"web"})
assert.NilError(t, err)
assert.DeepEqual(t, modelServiceNames(t, model), []string{"db", "web"})

// A service declaring its dependency in long form is handled too.
model, err = newOpts().ToModel(t.Context(), cli, []string{"api"})
assert.NilError(t, err)
assert.DeepEqual(t, modelServiceNames(t, model), []string{"api", "db"})

// A standalone service is rendered on its own.
model, err = newOpts().ToModel(t.Context(), cli, []string{"lonely"})
assert.NilError(t, err)
assert.DeepEqual(t, modelServiceNames(t, model), []string{"lonely"})

// Multiple selected services are unioned with their dependencies.
model, err = newOpts().ToModel(t.Context(), cli, []string{"web", "lonely"})
assert.NilError(t, err)
assert.DeepEqual(t, modelServiceNames(t, model), []string{"db", "lonely", "web"})

// No selection renders the whole model.
model, err = newOpts().ToModel(t.Context(), cli, nil)
assert.NilError(t, err)
assert.DeepEqual(t, modelServiceNames(t, model), []string{"api", "db", "lonely", "web"})

// An unknown service is rejected like the fully typed path does.
_, err = newOpts().ToModel(t.Context(), cli, []string{"nope"})
assert.Error(t, err, "no such service: nope")
})
}
}

func TestFilterModelServices(t *testing.T) {
baseModel := func() map[string]any {
return map[string]any{
"services": map[string]any{
// short (list) depends_on form
"web": map[string]any{"depends_on": []any{"db"}},
// long (map) depends_on form, transitively reaching db
"api": map[string]any{"depends_on": map[string]any{
"cache": map[string]any{"condition": "service_started", "required": true},
}},
"cache": map[string]any{"depends_on": []any{"db"}},
"db": map[string]any{},
"lonely": map[string]any{},
},
}
}

names := func(model map[string]any) []string {
out := []string{}
for name := range model["services"].(map[string]any) {
out = append(out, name)
}
sort.Strings(out)
return out
}

t.Run("no selection is a no-op", func(t *testing.T) {
model := baseModel()
assert.NilError(t, filterModelServices(model, nil))
assert.DeepEqual(t, names(model), []string{"api", "cache", "db", "lonely", "web"})
})

t.Run("transitive dependencies via list and map forms", func(t *testing.T) {
model := baseModel()
assert.NilError(t, filterModelServices(model, []string{"api"}))
assert.DeepEqual(t, names(model), []string{"api", "cache", "db"})
})

t.Run("selection with a single dependency", func(t *testing.T) {
model := baseModel()
assert.NilError(t, filterModelServices(model, []string{"web"}))
assert.DeepEqual(t, names(model), []string{"db", "web"})
})

t.Run("unknown service errors", func(t *testing.T) {
model := baseModel()
assert.Error(t, filterModelServices(model, []string{"missing"}), "no such service: missing")
})

t.Run("no services mapping errors when a service is requested", func(t *testing.T) {
assert.Error(t, filterModelServices(map[string]any{}, []string{"web"}), "no such service: web")
})
}