mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
feat(compose): warn on unsupported compose-file attributes
Compose silently ignored several spec-valid attributes it doesn't honor outside Swarm mode: deploy.update_config, credential_spec, ports[].mode: host, and others had no visible effect on a plain `docker compose up`. Detection is wired into compose-go's loader as a fail-closed allowlist: every attribute path the schema declares, minus the handful this runtime deliberately doesn't implement, plus a few value-conditional checks for attributes only unsupported for one specific value. A newly-specified attribute gets flagged automatically, with no corresponding code change needed here. Depends on an unreleased compose-go change; go.mod carries a temporary replace to a fork branch until it's released. Signed-off-by: Guillaume Lours <[email protected]>
This commit is contained in:
committed by
Nicolas De loof
parent
db9a43d7c1
commit
8018a96fde
@@ -94,7 +94,7 @@ func runConvert(ctx context.Context, dockerCli command.Cli, p *ProjectOptions, o
|
||||
return err
|
||||
}
|
||||
|
||||
project, _, err := p.ToProject(ctx, dockerCli, backend, nil)
|
||||
project, _, err := p.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ func runBuild(ctx context.Context, dockerCli command.Cli, backendOptions *Backen
|
||||
}
|
||||
|
||||
opts.All = true // do not drop resources as build may involve some dependencies by additional_contexts
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func completeServiceNames(dockerCli command.Cli, p *ProjectOptions) validArgsFn
|
||||
|
||||
// only service names are needed, so skip environment resolution: a missing
|
||||
// env_file must not prevent completion
|
||||
project, _, err := p.ToProject(cmd.Context(), dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
|
||||
project, _, err := p.ToProject(cmd.Context(), dockerCli, backend, nil, skipUnsupportedAttributesWarning, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func completeProfileNames(dockerCli command.Cli, p *ProjectOptions) validArgsFn
|
||||
|
||||
// only profile names are needed, so skip environment resolution: a missing
|
||||
// env_file must not prevent completion
|
||||
project, _, err := p.ToProject(cmd.Context(), dockerCli, backend, nil, cli.WithoutEnvironmentResolution)
|
||||
project, _, err := p.ToProject(cmd.Context(), dockerCli, backend, nil, skipUnsupportedAttributesWarning, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
+18
-6
@@ -173,7 +173,7 @@ func (o *ProjectOptions) WithServices(dockerCli command.Cli, fn ProjectServicesF
|
||||
return err
|
||||
}
|
||||
|
||||
project, metrics, err := o.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
|
||||
project, metrics, err := o.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func (o *ProjectOptions) projectOrName(ctx context.Context, dockerCli command.Cl
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
p, _, err := o.ToProject(ctx, dockerCli, backend, services, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution)
|
||||
p, _, err := o.ToProject(ctx, dockerCli, backend, services, skipUnsupportedAttributesWarning, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
envProjectName := os.Getenv(ComposeProjectName)
|
||||
if envProjectName != "" {
|
||||
@@ -281,7 +281,7 @@ func (o *ProjectOptions) toProjectName(ctx context.Context, dockerCli command.Cl
|
||||
return "", err
|
||||
}
|
||||
|
||||
project, _, err := o.ToProject(ctx, dockerCli, backend, nil, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution)
|
||||
project, _, err := o.ToProject(ctx, dockerCli, backend, nil, skipUnsupportedAttributesWarning, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -306,9 +306,14 @@ func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, ser
|
||||
return options.LoadModel(ctx)
|
||||
}
|
||||
|
||||
// ToProject loads a Compose project using the LoadProject API.
|
||||
// Accepts optional cli.ProjectOptionsFn to control loader behavior.
|
||||
func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, services []string, po ...cli.ProjectOptionsFn) (*types.Project, tracing.Metrics, error) {
|
||||
// ToProject loads a Compose project using the LoadProject API, then — when
|
||||
// warn is warnUnsupportedAttributes — warns about any unsupported-attribute
|
||||
// finding compose-go's loader reports during that load. Accepts optional
|
||||
// cli.ProjectOptionsFn to control loader behavior.
|
||||
func (o *ProjectOptions) ToProject(
|
||||
ctx context.Context, dockerCli command.Cli, backend api.Compose, services []string,
|
||||
warn unsupportedAttributeWarning, po ...cli.ProjectOptionsFn,
|
||||
) (*types.Project, tracing.Metrics, error) {
|
||||
var metrics tracing.Metrics
|
||||
remotes := o.remoteLoaders(dockerCli)
|
||||
|
||||
@@ -350,6 +355,13 @@ func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, b
|
||||
LoadListeners: []api.LoadListener{metricsListener},
|
||||
OCI: o.ociOptions(),
|
||||
}
|
||||
if warn == warnUnsupportedAttributes {
|
||||
loadOpts.OnUnsupportedAttribute = func(findings []api.UnsupportedAttribute) {
|
||||
for _, finding := range findings {
|
||||
logrus.Warn(finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
project, err := backend.LoadProject(ctx, loadOpts)
|
||||
if err != nil {
|
||||
|
||||
@@ -63,9 +63,11 @@ type configOptions struct {
|
||||
lockImageDigests bool
|
||||
}
|
||||
|
||||
// ToProject always warns: every config subcommand renders the resolved
|
||||
// model to the user, so any unsupported-attribute finding is relevant here.
|
||||
func (o *configOptions) ToProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, services []string, po ...cli.ProjectOptionsFn) (*types.Project, error) {
|
||||
po = append(po, o.toProjectOptionsFns()...)
|
||||
project, _, err := o.ProjectOptions.ToProject(ctx, dockerCli, backend, services, po...)
|
||||
project, _, err := o.ProjectOptions.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, po...)
|
||||
return project, err
|
||||
}
|
||||
|
||||
|
||||
+23
-37
@@ -17,8 +17,6 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -27,8 +25,6 @@ import (
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
"github.com/moby/moby/client"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"go.uber.org/mock/gomock"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
@@ -168,47 +164,37 @@ func TestImagesOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWarnHooksNotLockable(t *testing.T) {
|
||||
hook := logrustest.NewGlobal()
|
||||
logrus.SetOutput(io.Discard)
|
||||
defer func() {
|
||||
logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks))
|
||||
logrus.SetOutput(os.Stderr)
|
||||
}()
|
||||
|
||||
warnHooksNotLockable(&types.Project{
|
||||
Services: types.Services{
|
||||
"with-hook-image": types.ServiceConfig{PreStart: []types.ServiceHook{{Image: "alpine:latest"}}},
|
||||
"inline-hook": types.ServiceConfig{PreStart: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}},
|
||||
"without-hook": types.ServiceConfig{},
|
||||
},
|
||||
messages := captureWarnings(t, func() {
|
||||
warnHooksNotLockable(&types.Project{
|
||||
Services: types.Services{
|
||||
"with-hook-image": types.ServiceConfig{PreStart: []types.ServiceHook{{Image: "alpine:latest"}}},
|
||||
"inline-hook": types.ServiceConfig{PreStart: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}},
|
||||
"without-hook": types.ServiceConfig{},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
assert.Equal(t, len(hook.Entries), 1)
|
||||
assert.Assert(t, strings.Contains(hook.Entries[0].Message, `service "with-hook-image"`))
|
||||
assert.Equal(t, len(messages), 1)
|
||||
assert.Assert(t, strings.Contains(messages[0], `service "with-hook-image"`))
|
||||
}
|
||||
|
||||
func TestWarnModelHooksNotLockable(t *testing.T) {
|
||||
hook := logrustest.NewGlobal()
|
||||
logrus.SetOutput(io.Discard)
|
||||
defer func() {
|
||||
logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks))
|
||||
logrus.SetOutput(os.Stderr)
|
||||
}()
|
||||
|
||||
warnModelHooksNotLockable(map[string]any{
|
||||
"services": map[string]any{
|
||||
"with-hook-image": map[string]any{
|
||||
"pre_start": []any{map[string]any{"image": "alpine:latest"}},
|
||||
messages := captureWarnings(t, func() {
|
||||
warnModelHooksNotLockable(map[string]any{
|
||||
"services": map[string]any{
|
||||
"with-hook-image": map[string]any{
|
||||
"pre_start": []any{map[string]any{"image": "alpine:latest"}},
|
||||
},
|
||||
"inline-hook": map[string]any{
|
||||
"pre_start": []any{map[string]any{"command": "echo"}},
|
||||
},
|
||||
"without-hook": map[string]any{"image": "nginx"},
|
||||
},
|
||||
"inline-hook": map[string]any{
|
||||
"pre_start": []any{map[string]any{"command": "echo"}},
|
||||
},
|
||||
"without-hook": map[string]any{"image": "nginx"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
assert.Equal(t, len(hook.Entries), 1)
|
||||
assert.Assert(t, strings.Contains(hook.Entries[0].Message, `service "with-hook-image"`))
|
||||
assert.Equal(t, len(messages), 1)
|
||||
assert.Assert(t, strings.Contains(messages[0], `service "with-hook-image"`))
|
||||
}
|
||||
|
||||
func TestLockModel(t *testing.T) {
|
||||
|
||||
@@ -83,7 +83,7 @@ func runPublish(ctx context.Context, dockerCli command.Cli, backendOptions *Back
|
||||
return err
|
||||
}
|
||||
|
||||
project, metrics, err := opts.ToProject(ctx, dockerCli, backend, nil)
|
||||
project, metrics, err := opts.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ func runPull(ctx context.Context, dockerCli command.Cli, backendOptions *Backend
|
||||
return err
|
||||
}
|
||||
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ func runPush(ctx context.Context, dockerCli command.Cli, backendOptions *Backend
|
||||
return err
|
||||
}
|
||||
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, services)
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+1
-1
@@ -271,7 +271,7 @@ func normalizeRunFlags(f *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
// dependencies started by run, so hashing a different value would recreate
|
||||
// their containers.
|
||||
func runProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, p *ProjectOptions, service string) (*types.Project, error) {
|
||||
project, _, err := p.ToProject(ctx, dockerCli, backend, []string{service}, composecli.WithoutEnvironmentResolution)
|
||||
project, _, err := p.ToProject(ctx, dockerCli, backend, []string{service}, warnUnsupportedAttributes, composecli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func runScale(ctx context.Context, dockerCli command.Cli, backendOptions *Backen
|
||||
}
|
||||
|
||||
services := slices.Sorted(maps.Keys(serviceReplicaTuples))
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
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
|
||||
|
||||
// unsupportedAttributeWarning is a distinct type rather than a bool so every
|
||||
// ToProject call site must name one of the constants below.
|
||||
type unsupportedAttributeWarning int
|
||||
|
||||
const (
|
||||
warnUnsupportedAttributes unsupportedAttributeWarning = iota
|
||||
skipUnsupportedAttributesWarning
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
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 (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
realcompose "github.com/docker/compose/v5/pkg/compose"
|
||||
)
|
||||
|
||||
// unsupportedAttrFixture writes a compose file with an attribute
|
||||
// (deploy.mode) that the unsupported-attribute check always flags, so any
|
||||
// code path that loads it can be checked for whether it warns or stays
|
||||
// silent.
|
||||
func unsupportedAttrFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "compose.yaml")
|
||||
content := `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
mode: replicated
|
||||
`
|
||||
assert.NilError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
return path
|
||||
}
|
||||
|
||||
// captureWarnings runs fn with a global logrus hook installed and returns
|
||||
// every message logged during the call.
|
||||
func captureWarnings(t *testing.T, fn func()) []string {
|
||||
t.Helper()
|
||||
hook := logrustest.NewGlobal()
|
||||
logrus.SetOutput(io.Discard)
|
||||
defer func() {
|
||||
logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks))
|
||||
logrus.SetOutput(os.Stderr)
|
||||
}()
|
||||
fn()
|
||||
messages := make([]string, len(hook.Entries))
|
||||
for i, entry := range hook.Entries {
|
||||
messages[i] = entry.Message
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// ToProject is the "real" load path used by commands that act on the full
|
||||
// compose model (build, config, run, scale, watch, ...): it must warn.
|
||||
func TestToProject_WarnsOnUnsupportedAttributes(t *testing.T) {
|
||||
opts := &ProjectOptions{ConfigPaths: []string{unsupportedAttrFixture(t)}}
|
||||
backend, err := realcompose.NewComposeService(nil)
|
||||
assert.NilError(t, err)
|
||||
|
||||
messages := captureWarnings(t, func() {
|
||||
_, _, err := opts.ToProject(t.Context(), nil, backend, nil, warnUnsupportedAttributes)
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
|
||||
assert.Assert(t, len(messages) > 0, "ToProject should warn about deploy.mode")
|
||||
}
|
||||
|
||||
func TestToProject_SkipsWarningWhenRequested(t *testing.T) {
|
||||
opts := &ProjectOptions{ConfigPaths: []string{unsupportedAttrFixture(t)}}
|
||||
backend, err := realcompose.NewComposeService(nil)
|
||||
assert.NilError(t, err)
|
||||
|
||||
messages := captureWarnings(t, func() {
|
||||
_, _, err := opts.ToProject(t.Context(), nil, backend, nil, skipUnsupportedAttributesWarning)
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
|
||||
assert.Equal(t, len(messages), 0)
|
||||
}
|
||||
|
||||
// projectOrName and toProjectName are lightweight project-name resolution
|
||||
// helpers used by commands that operate on already-running
|
||||
// containers/services by name (down, stop, ps, logs, ...) or by shell
|
||||
// completion (completeServiceNames, completeProfileNames). Warning here
|
||||
// would fire on every such invocation regardless of whether the command has
|
||||
// anything to do with the flagged attribute, and even during tab-completion
|
||||
// — see docker/compose#14196 review discussion.
|
||||
func TestProjectOrName_DoesNotWarnOnUnsupportedAttributes(t *testing.T) {
|
||||
opts := &ProjectOptions{ConfigPaths: []string{unsupportedAttrFixture(t)}}
|
||||
|
||||
messages := captureWarnings(t, func() {
|
||||
_, _, err := opts.projectOrName(t.Context(), nil)
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
|
||||
assert.Equal(t, len(messages), 0)
|
||||
}
|
||||
|
||||
func TestToProjectName_DoesNotWarnOnUnsupportedAttributes(t *testing.T) {
|
||||
opts := &ProjectOptions{ConfigPaths: []string{unsupportedAttrFixture(t)}}
|
||||
|
||||
messages := captureWarnings(t, func() {
|
||||
_, err := opts.toProjectName(t.Context(), nil)
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
|
||||
assert.Equal(t, len(messages), 0)
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ func runViz(ctx context.Context, dockerCli command.Cli, backendOptions *BackendO
|
||||
return err
|
||||
}
|
||||
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, nil)
|
||||
project, _, err := opts.ToProject(ctx, dockerCli, backend, nil, warnUnsupportedAttributes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func runWatch(ctx context.Context, dockerCli command.Cli, backendOptions *Backen
|
||||
return err
|
||||
}
|
||||
|
||||
project, _, err := watchOpts.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
|
||||
project, _, err := watchOpts.ToProject(ctx, dockerCli, backend, services, warnUnsupportedAttributes, cli.WithoutEnvironmentResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
|
||||
github.com/buger/goterm v1.0.4
|
||||
github.com/compose-spec/compose-go/v2 v2.15.0
|
||||
github.com/compose-spec/compose-go/v2 v2.15.1-0.20260908103050-cda18529aca7
|
||||
github.com/containerd/console v1.0.5
|
||||
github.com/containerd/containerd/v2 v2.3.5
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
|
||||
@@ -32,8 +32,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
||||
github.com/compose-spec/compose-go/v2 v2.15.0 h1:tdQw+eMyT+P6ZIb09JfcIVvbMmIa+PjST7cWezVLf00=
|
||||
github.com/compose-spec/compose-go/v2 v2.15.0/go.mod h1:Q1+qtN4vhzEjGrnqRtzx1xa8raDZQlMUe3WJxndYNiQ=
|
||||
github.com/compose-spec/compose-go/v2 v2.15.1-0.20260908103050-cda18529aca7 h1:D9jScaeUAk7DjyB71SD9zvOFPR6klw0FxPcR2bgu72c=
|
||||
github.com/compose-spec/compose-go/v2 v2.15.1-0.20260908103050-cda18529aca7/go.mod h1:Q1+qtN4vhzEjGrnqRtzx1xa8raDZQlMUe3WJxndYNiQ=
|
||||
github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ=
|
||||
github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw=
|
||||
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
|
||||
|
||||
@@ -74,6 +74,12 @@ type ProjectLoadOptions struct {
|
||||
// This is optional - pass nil or empty slice if not needed.
|
||||
LoadListeners []LoadListener
|
||||
|
||||
// OnUnsupportedAttribute, when set, is invoked once during loading with
|
||||
// every compose-file attribute detected as unsupported by this runtime
|
||||
// outside Swarm mode. Detection only runs when this is set; leave nil to
|
||||
// skip it entirely (e.g. for name-only project resolution).
|
||||
OnUnsupportedAttribute func([]UnsupportedAttribute)
|
||||
|
||||
OCI OCIOptions
|
||||
}
|
||||
|
||||
@@ -161,9 +167,27 @@ type Compose interface {
|
||||
// Volumes executes the equivalent to a `docker volume ls`
|
||||
Volumes(ctx context.Context, project string, options VolumesOptions) ([]VolumesSummary, error)
|
||||
// LoadProject loads and validates a Compose project from configuration files.
|
||||
// Set ProjectLoadOptions.OnUnsupportedAttribute to also be notified of
|
||||
// compose-file attributes accepted by the schema but not honored by this
|
||||
// runtime outside Swarm mode.
|
||||
LoadProject(ctx context.Context, options ProjectLoadOptions) (*types.Project, error)
|
||||
}
|
||||
|
||||
// UnsupportedAttribute reports a compose-file attribute that is accepted by
|
||||
// the schema but has no effect on this runtime outside Swarm mode.
|
||||
type UnsupportedAttribute struct {
|
||||
Service string // service name; empty for project-scoped attributes
|
||||
Path string // dotted attribute path, e.g. "deploy.update_config.failure_action"
|
||||
Reason string // one-line human-readable explanation, ready to print as-is
|
||||
}
|
||||
|
||||
func (u UnsupportedAttribute) String() string {
|
||||
if u.Service == "" {
|
||||
return fmt.Sprintf("%s: %s", u.Path, u.Reason)
|
||||
}
|
||||
return fmt.Sprintf("service %q: %s: %s", u.Service, u.Path, u.Reason)
|
||||
}
|
||||
|
||||
type VolumesOptions struct {
|
||||
Services []string
|
||||
}
|
||||
|
||||
@@ -1229,10 +1229,6 @@ func buildContainerConfigMounts(p types.Project, s types.ServiceConfig) ([]mount
|
||||
continue
|
||||
}
|
||||
|
||||
if config.UID != "" || config.GID != "" || config.Mode != nil {
|
||||
logrus.Warn("config `uid`, `gid` and `mode` are not supported, they will be ignored")
|
||||
}
|
||||
|
||||
bindMount, err := buildMount(p, types.ServiceVolumeConfig{
|
||||
Type: types.VolumeTypeBind,
|
||||
Source: definedConfig.File,
|
||||
@@ -1279,10 +1275,6 @@ func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount
|
||||
continue
|
||||
}
|
||||
|
||||
if secret.UID != "" || secret.GID != "" || secret.Mode != nil {
|
||||
logrus.Warn("secrets `uid`, `gid` and `mode` are not supported, they will be ignored")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(definedSecret.File); os.IsNotExist(err) {
|
||||
logrus.Warnf("secret file %s does not exist", definedSecret.Name)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ func (s *composeService) buildProjectOptions(options api.ProjectLoadOptions, rem
|
||||
cli.WithName(options.ProjectName),
|
||||
)
|
||||
|
||||
if options.OnUnsupportedAttribute != nil {
|
||||
projectOptionsFns = append(projectOptionsFns, unsupportedAttributesLoadOption(options.OnUnsupportedAttribute))
|
||||
}
|
||||
|
||||
return cli.NewProjectOptions(options.ConfigPaths, append(options.ProjectOptionsFns, projectOptionsFns...)...)
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
|
||||
}
|
||||
|
||||
eg.Go(func() error {
|
||||
return s.waitDependency(ctx, dep, config, waitingFor)
|
||||
return s.waitDependency(ctx, dependant, dep, config, waitingFor)
|
||||
})
|
||||
}
|
||||
err := eg.Wait()
|
||||
@@ -197,7 +197,7 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
|
||||
// waitDependency polls the dependency's containers until its depends_on
|
||||
// condition is satisfied (done), definitively failed (err), or ctx is
|
||||
// cancelled. Each check reports (done, err): (false, nil) means keep polling.
|
||||
func (s *composeService) waitDependency(ctx context.Context, dep string, config types.ServiceDependency, waitingFor Containers) error {
|
||||
func (s *composeService) waitDependency(ctx context.Context, dependant, dep string, config types.ServiceDependency, waitingFor Containers) error {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -223,7 +223,20 @@ func (s *composeService) waitDependency(ctx context.Context, dep string, config
|
||||
case types.ServiceConditionCompletedSuccessfully:
|
||||
done, err = s.checkDependencyCompleted(ctx, dep, config, waitingFor)
|
||||
default:
|
||||
logrus.Warnf("unsupported depends_on condition: %s", config.Condition)
|
||||
// Every condition this switch doesn't handle explicitly ends up
|
||||
// here: ServiceConditionStarted is filtered out before this
|
||||
// function is ever called (shouldWaitForDependency — "already
|
||||
// managed by InDependencyOrder"), so nothing reaching this
|
||||
// branch is ever a value this runtime actually understands.
|
||||
// That covers a compose file with an unrecognized condition
|
||||
// (rejected by schema.Validate before it can even get this far
|
||||
// — see unsupported_attributes.go's comment on
|
||||
// valueConditionalAttributes) as well as the two situations
|
||||
// that never go through schema validation at all: a project
|
||||
// rebuilt from live container labels (see projectFromName), or
|
||||
// one written by an older/newer Compose version using a
|
||||
// condition value this build doesn't recognize.
|
||||
logrus.Warnf("service %q: unsupported depends_on condition %q, skipping wait for %q", dependant, config.Condition, dep)
|
||||
return nil
|
||||
}
|
||||
if done || err != nil {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
"github.com/moby/moby/client"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"go.uber.org/mock/gomock"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
@@ -812,4 +813,22 @@ func TestWaitDependencyDeadline(t *testing.T) {
|
||||
err := tested.(*composeService).waitDependencies(ctx, &project, "app", dependencies, containers, 0)
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
|
||||
// Regression guard for the runtime fallback warning on waitDependency's
|
||||
// default branch — see the comment on that branch for why it must stay.
|
||||
t.Run("unsupported condition warns and returns without waiting", func(t *testing.T) {
|
||||
hook := logrustest.NewGlobal()
|
||||
unsupportedDeps := types.DependsOnConfig{
|
||||
"db": {Condition: "some_future_condition", Required: true},
|
||||
}
|
||||
err := tested.(*composeService).waitDependencies(t.Context(), &project, "app", unsupportedDeps, containers, 2*time.Second)
|
||||
assert.NilError(t, err)
|
||||
|
||||
var messages []string
|
||||
for _, e := range hook.AllEntries() {
|
||||
messages = append(messages, e.Message)
|
||||
}
|
||||
joined := strings.Join(messages, "\n")
|
||||
assert.Assert(t, strings.Contains(joined, `service "app": unsupported depends_on condition "some_future_condition"`), joined)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
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 (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/compose-spec/compose-go/v2/loader"
|
||||
"github.com/compose-spec/compose-go/v2/schema"
|
||||
"github.com/compose-spec/compose-go/v2/tree"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
// genericUnsupportedReason is used for a finding that matches neither
|
||||
// presenceUnsupportedAttributes nor valueConditionalAttributes: an attribute
|
||||
// the compose-spec schema gained after this file was last updated, caught
|
||||
// only because it's absent from supportedAttributePaths. This is the whole
|
||||
// point of allowlisting instead of denylisting — see that var's comment.
|
||||
const genericUnsupportedReason = "not honored by this runtime outside Swarm mode"
|
||||
|
||||
// presenceUnsupportedAttributes lists, by path, every schema-declared
|
||||
// attribute this runtime never honors outside Swarm mode regardless of its
|
||||
// value — presence alone is the finding. Their paths are removed from
|
||||
// schema.AttributePaths() (the full compose-spec inventory) to build
|
||||
// supportedAttributePaths, the allowlist handed to compose-go via
|
||||
// loader.WithSupportedAttributes: any OTHER attribute the specification
|
||||
// adds later is reported too, with genericUnsupportedReason, until it's
|
||||
// deliberately added here (or to valueConditionalAttributes) with a
|
||||
// considered message. This is what makes the check fail-closed instead of
|
||||
// silently missing whatever gets added to the spec next — the exact gap
|
||||
// docker/compose#13150 was filed for.
|
||||
var presenceUnsupportedAttributes = map[tree.Path]string{
|
||||
tree.NewPath("services", "*", "deploy", "mode"): "deploy.mode is only honored in Swarm mode",
|
||||
tree.NewPath("services", "*", "deploy", "labels"): "deploy.labels are only applied to Swarm services",
|
||||
tree.NewPath("services", "*", "deploy", "update_config"): "deploy.update_config only applies to rolling updates in Swarm mode",
|
||||
tree.NewPath("services", "*", "deploy", "rollback_config"): "deploy.rollback_config only applies to rolling updates in Swarm mode",
|
||||
tree.NewPath("services", "*", "deploy", "endpoint_mode"): "deploy.endpoint_mode only applies to Swarm's routing mesh",
|
||||
tree.NewPath("services", "*", "deploy", "placement"): "deploy.placement is only honored by the Swarm scheduler",
|
||||
tree.NewPath("services", "*", "credential_spec"): "credential_spec is only supported when running Windows containers under Swarm",
|
||||
tree.NewPath("configs", "*", "labels"): "configs[].labels are only applied to Swarm config objects",
|
||||
tree.NewPath("secrets", "*", "driver_opts"): "secrets[].driver_opts is only honored by Swarm secret drivers",
|
||||
tree.NewPath("secrets", "*", "labels"): "secrets[].labels are only applied to Swarm secret objects",
|
||||
}
|
||||
|
||||
// supportedAttributePaths is the allowlist handed to compose-go: every
|
||||
// compose-spec attribute path except the ones this runtime deliberately
|
||||
// never honors (presenceUnsupportedAttributes) and their descendants.
|
||||
// Several removed paths (deploy.update_config, deploy.rollback_config,
|
||||
// deploy.placement, credential_spec) are themselves objects with their own
|
||||
// schema-declared sub-fields: schema.AttributePaths() lists those
|
||||
// sub-fields as separate entries too, so removing only the exact parent
|
||||
// path would leave them in Supported — making compose-go's walk see a
|
||||
// supported descendant below the removed node (Matcher.MayContain) and
|
||||
// descend into it instead of reporting the parent as a whole, silently
|
||||
// undoing the removal. Stripping every path that has a removed one as a
|
||||
// prefix — not just an exact match — is what makes the parent-level removal
|
||||
// actually take.
|
||||
//
|
||||
// Computed lazily (schema.AttributePaths() parses the embedded compose-spec
|
||||
// schema on first call) rather than at package init: most compose commands
|
||||
// never set OnUnsupportedAttribute at all (e.g. shell completion, or any
|
||||
// command that skips the warning — see unsupportedAttributesLoadOption's
|
||||
// only caller), so paying this cost eagerly for every invocation would be
|
||||
// wasted on them.
|
||||
var supportedAttributePaths = sync.OnceValue(buildSupportedAttributePaths)
|
||||
|
||||
func buildSupportedAttributePaths() []tree.Path {
|
||||
all := schema.AttributePaths()
|
||||
supported := make([]tree.Path, 0, len(all))
|
||||
for _, path := range all {
|
||||
if isUnderAnyOf(path, presenceUnsupportedAttributes) {
|
||||
continue
|
||||
}
|
||||
supported = append(supported, path)
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
// isUnderAnyOf reports whether path equals, or is a descendant of, one of
|
||||
// removed's keys: truncated to the prefix's own length, path matches it
|
||||
// under tree.Path's usual "*"/"[]" wildcard rules.
|
||||
func isUnderAnyOf(path tree.Path, removed map[tree.Path]string) bool {
|
||||
pathParts := path.Parts()
|
||||
for prefix := range removed {
|
||||
prefixParts := prefix.Parts()
|
||||
if len(pathParts) < len(prefixParts) {
|
||||
continue
|
||||
}
|
||||
if tree.NewPath(pathParts[:len(prefixParts)]...).Matches(prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// valueConditionalAttributes lists the checks that need a value predicate,
|
||||
// not mere presence: compose-go's loader defaults each of these paths to a
|
||||
// common, supported value on virtually every occurrence (ports[].mode to
|
||||
// "ingress", volumes[].type to "bind"/"volume", every depends_on entry has
|
||||
// some condition), so presence alone would fire on nearly every service —
|
||||
// only a specific value is unsupported. They can never be derived from
|
||||
// schema.AttributePaths() (compose-go's allowlist only knows about presence,
|
||||
// not values), so they stay hand-written regardless of how
|
||||
// presenceUnsupportedAttributes evolves.
|
||||
//
|
||||
// ports[].mode, volumes[].type and the two file-reference checks target the
|
||||
// whole node (the map compose-go hands back for that path), not a scalar
|
||||
// leaf: identifying which port/volume/reference triggered the finding needs
|
||||
// a sibling field (target/protocol, source) only visible from that node — a
|
||||
// list index collapses to the literal token "[]" in the reported Path,
|
||||
// losing identity, unlike a map key (a service or dependency name), which
|
||||
// stays.
|
||||
//
|
||||
// depends_on.*.condition has no entry here even though it's the same kind
|
||||
// of "only some values are supported" case: the compose-spec schema itself
|
||||
// declares condition as a closed enum of the 3 values this runtime
|
||||
// understands, so schema.Validate — which runs before this check, on every
|
||||
// load — already rejects anything else. There is no unsupported value left
|
||||
// for a real compose file to reach this check with (see
|
||||
// TestDependsOnUnknownConditionIsRejectedBySchema). waitDependency's own
|
||||
// runtime fallback (service_containers.go) still needs to warn about an
|
||||
// unrecognized condition, for the two situations that never go through
|
||||
// schema validation at all: a project rebuilt from container labels, or an
|
||||
// older/newer Compose version's output.
|
||||
var valueConditionalAttributes = []struct {
|
||||
Pattern tree.Path
|
||||
Detect func(value any) bool
|
||||
// Report returns the api.UnsupportedAttribute(s) for a match — Service
|
||||
// is always left blank here; toAPIUnsupportedAttributes fills it in
|
||||
// from the finding's Path, which Report doesn't have direct access to.
|
||||
Report func(finding loader.UnsupportedAttribute) []api.UnsupportedAttribute
|
||||
}{
|
||||
{
|
||||
Pattern: tree.NewPath("services", "*", "ports", "[]"),
|
||||
Detect: func(v any) bool {
|
||||
port, _ := v.(map[string]any)
|
||||
return port["mode"] == "host"
|
||||
},
|
||||
Report: func(finding loader.UnsupportedAttribute) []api.UnsupportedAttribute {
|
||||
port, _ := finding.Value.(map[string]any)
|
||||
path := fmt.Sprintf("ports[%v/%v].mode", port["target"], port["protocol"])
|
||||
return []api.UnsupportedAttribute{{Path: path, Reason: "ports[].mode: host is only honored by the Swarm routing mesh"}}
|
||||
},
|
||||
},
|
||||
{
|
||||
Pattern: tree.NewPath("services", "*", "volumes", "[]"),
|
||||
Detect: func(v any) bool {
|
||||
volume, _ := v.(map[string]any)
|
||||
return volume["type"] == "cluster"
|
||||
},
|
||||
Report: func(finding loader.UnsupportedAttribute) []api.UnsupportedAttribute {
|
||||
volume, _ := finding.Value.(map[string]any)
|
||||
path := fmt.Sprintf("volumes[%v].type", volume["source"])
|
||||
return []api.UnsupportedAttribute{{Path: path, Reason: "volumes[].type: cluster (CSI) volumes are only supported in Swarm mode"}}
|
||||
},
|
||||
},
|
||||
{
|
||||
Pattern: tree.NewPath("services", "*", "configs", "[]"),
|
||||
Detect: hasFileReferenceOverride,
|
||||
Report: fileReferenceReport("configs"),
|
||||
},
|
||||
{
|
||||
Pattern: tree.NewPath("services", "*", "secrets", "[]"),
|
||||
Detect: hasFileReferenceOverride,
|
||||
Report: fileReferenceReport("secrets"),
|
||||
},
|
||||
}
|
||||
|
||||
func hasFileReferenceOverride(v any) bool {
|
||||
ref, _ := v.(map[string]any)
|
||||
return ref["uid"] != nil || ref["gid"] != nil || ref["mode"] != nil
|
||||
}
|
||||
|
||||
// fileReferenceReport drives the configs/secrets entries above: uid/gid/mode
|
||||
// on a service-level configs:/secrets: reference are silently ignored
|
||||
// outside Swarm mode. Each non-nil sub-field on the matched reference
|
||||
// produces its own finding, identified by the reference's source, so
|
||||
// multiple references — or multiple flagged sub-fields on the same one —
|
||||
// stay distinguishable.
|
||||
func fileReferenceReport(kind string) func(loader.UnsupportedAttribute) []api.UnsupportedAttribute {
|
||||
return func(finding loader.UnsupportedAttribute) []api.UnsupportedAttribute {
|
||||
ref, _ := finding.Value.(map[string]any)
|
||||
// source is schema-optional on the long form (compose-spec's
|
||||
// service_config_or_secret has no "required"), so a malformed
|
||||
// reference like `configs: [{uid: "1000"}]` is valid enough to
|
||||
// reach here without one.
|
||||
source, _ := ref["source"].(string)
|
||||
if source == "" {
|
||||
source = "(anonymous)"
|
||||
}
|
||||
var findings []api.UnsupportedAttribute
|
||||
for _, field := range []string{"uid", "gid", "mode"} {
|
||||
if ref[field] == nil {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, api.UnsupportedAttribute{
|
||||
Path: fmt.Sprintf("%s.%s.%s", kind, source, field),
|
||||
Reason: field + " is not supported outside Swarm mode and will be ignored",
|
||||
})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
}
|
||||
|
||||
// splitServicePath splits a "services.<name>...." finding into its service
|
||||
// name and the remaining dotted path (matching this package's existing
|
||||
// attribute-path convention); a project-scoped finding (configs.*/secrets.*)
|
||||
// has no service and keeps its full path as-is — it already carries its
|
||||
// resource name as a literal segment.
|
||||
func splitServicePath(path tree.Path) (service, outputPath string) {
|
||||
parts := path.Parts()
|
||||
if len(parts) >= 2 && parts[0] == "services" {
|
||||
return parts[1], strings.Join(parts[2:], ".")
|
||||
}
|
||||
return "", path.String()
|
||||
}
|
||||
|
||||
// toAPIUnsupportedAttributes converts compose-go's raw findings to this
|
||||
// package's api.UnsupportedAttribute. A finding either matches one of
|
||||
// valueConditionalAttributes (which knows how to render it from Value), or
|
||||
// it came from the supportedAttributePaths allowlist screening — in which
|
||||
// case presenceUnsupportedAttributes supplies the reason when this is a
|
||||
// known case, and genericUnsupportedReason otherwise.
|
||||
func toAPIUnsupportedAttributes(findings []loader.UnsupportedAttribute) []api.UnsupportedAttribute {
|
||||
var result []api.UnsupportedAttribute
|
||||
for _, finding := range findings {
|
||||
service, outputPath := splitServicePath(finding.Path)
|
||||
|
||||
if vc, findings := matchValueConditional(finding); vc {
|
||||
for i := range findings {
|
||||
findings[i].Service = service
|
||||
}
|
||||
result = append(result, findings...)
|
||||
continue
|
||||
}
|
||||
|
||||
reason := genericUnsupportedReason
|
||||
for pattern, r := range presenceUnsupportedAttributes {
|
||||
if finding.Path.Matches(pattern) {
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, api.UnsupportedAttribute{Service: service, Path: outputPath, Reason: reason})
|
||||
}
|
||||
slices.SortFunc(result, func(a, b api.UnsupportedAttribute) int {
|
||||
if c := cmp.Compare(a.Service, b.Service); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.Path, b.Path)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func matchValueConditional(finding loader.UnsupportedAttribute) (bool, []api.UnsupportedAttribute) {
|
||||
for _, vc := range valueConditionalAttributes {
|
||||
if finding.Path.Matches(vc.Pattern) {
|
||||
return true, vc.Report(finding)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// unsupportedAttributesLoadOption registers compose-go's unsupported-attribute
|
||||
// detection with the loader — both the value-conditional patterns and the
|
||||
// supported-paths allowlist feed the same walk — invoking report once
|
||||
// loading completes with every match translated to api.UnsupportedAttribute.
|
||||
func unsupportedAttributesLoadOption(report func([]api.UnsupportedAttribute)) cli.ProjectOptionsFn {
|
||||
patterns := make([]loader.UnsupportedAttributePattern, len(valueConditionalAttributes))
|
||||
for i, vc := range valueConditionalAttributes {
|
||||
patterns[i] = loader.UnsupportedAttributePattern{Path: vc.Pattern, Detect: vc.Detect}
|
||||
}
|
||||
wrap := func(findings []loader.UnsupportedAttribute) {
|
||||
report(toAPIUnsupportedAttributes(findings))
|
||||
}
|
||||
return cli.WithLoadOptions(
|
||||
loader.WithUnsupportedAttributesCheck(patterns, wrap),
|
||||
loader.WithSupportedAttributes(supportedAttributePaths(), nil),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
/*
|
||||
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 (
|
||||
"cmp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/schema"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
// findingKey is the subset of api.UnsupportedAttribute we compare against a
|
||||
// fixed expectation: Reason wording isn't part of the contract, only that a
|
||||
// finding was raised for a given Service/Path pair (and that it carries a
|
||||
// non-empty Reason, checked separately).
|
||||
type findingKey struct {
|
||||
Service string
|
||||
Path string
|
||||
}
|
||||
|
||||
func findingKeys(t *testing.T, findings []api.UnsupportedAttribute) []findingKey {
|
||||
t.Helper()
|
||||
keys := make([]findingKey, 0, len(findings))
|
||||
for _, f := range findings {
|
||||
assert.Assert(t, f.Reason != "", "finding %+v is missing a Reason", f)
|
||||
keys = append(keys, findingKey{Service: f.Service, Path: f.Path})
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// loadWithFiles writes files (name -> content) into a fresh temp dir and
|
||||
// loads "compose.yaml" from it through the real compose-go loader with
|
||||
// unsupported-attribute detection enabled, returning every finding.
|
||||
// Detection is now inherently a load-time hook (compose-go's own
|
||||
// tree-walker over the raw, un-normalized model): there is no longer a
|
||||
// pure function to call directly on a hand-built types.Project, so every
|
||||
// case here goes through an actual load.
|
||||
func loadWithFiles(t *testing.T, files map[string]string) []api.UnsupportedAttribute {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
for name, content := range files {
|
||||
path := filepath.Join(tmpDir, name)
|
||||
assert.NilError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
assert.NilError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
service, err := NewComposeService(nil)
|
||||
assert.NilError(t, err)
|
||||
|
||||
var findings []api.UnsupportedAttribute
|
||||
_, err = service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{filepath.Join(tmpDir, "compose.yaml")},
|
||||
OnUnsupportedAttribute: func(f []api.UnsupportedAttribute) {
|
||||
findings = f
|
||||
},
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
return findings
|
||||
}
|
||||
|
||||
func loadCompose(t *testing.T, yaml string) []api.UnsupportedAttribute {
|
||||
t.Helper()
|
||||
return loadWithFiles(t, map[string]string{"compose.yaml": yaml})
|
||||
}
|
||||
|
||||
func assertFindings(t *testing.T, got []api.UnsupportedAttribute, expected []findingKey) {
|
||||
t.Helper()
|
||||
slices.SortFunc(expected, func(a, b findingKey) int {
|
||||
if c := cmp.Compare(a.Service, b.Service); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.Path, b.Path)
|
||||
})
|
||||
assert.DeepEqual(t, findingKeys(t, got), expected, cmpopts.EquateEmpty())
|
||||
}
|
||||
|
||||
func TestUnsupportedAttributes(t *testing.T) {
|
||||
t.Run("deploy mode is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
mode: replicated
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.mode"}})
|
||||
})
|
||||
|
||||
t.Run("deploy labels is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
labels:
|
||||
team: backend
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.labels"}})
|
||||
})
|
||||
|
||||
t.Run("deploy update_config is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
update_config:
|
||||
parallelism: 1
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.update_config"}})
|
||||
})
|
||||
|
||||
t.Run("deploy rollback_config is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.rollback_config"}})
|
||||
})
|
||||
|
||||
// Placement covers three independent sub-fields (constraints,
|
||||
// preferences, max_replicas_per_node). A single finding is reported for
|
||||
// the whole "deploy.placement" node regardless of which sub-field(s)
|
||||
// triggered it, rather than one finding per sub-field: they're only
|
||||
// ever set together in practice and placement has no further nesting
|
||||
// worth distinguishing in the report.
|
||||
t.Run("deploy placement constraints is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
placement:
|
||||
constraints:
|
||||
- node.role==manager
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.placement"}})
|
||||
})
|
||||
|
||||
t.Run("deploy placement preferences is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
placement:
|
||||
preferences:
|
||||
- spread: node.labels.zone
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.placement"}})
|
||||
})
|
||||
|
||||
t.Run("deploy placement max_replicas_per_node is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
placement:
|
||||
max_replicas_per_node: 2
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.placement"}})
|
||||
})
|
||||
|
||||
t.Run("deploy endpoint_mode is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
endpoint_mode: vip
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "deploy.endpoint_mode"}})
|
||||
})
|
||||
|
||||
t.Run("credential_spec is unsupported", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
credential_spec:
|
||||
file: ./creds.json
|
||||
`,
|
||||
"creds.json": "{}",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "credential_spec"}})
|
||||
})
|
||||
|
||||
// label_file is NOT in presenceUnsupportedAttributes: compose-go's
|
||||
// loader resolves it into the service's actual Labels by default
|
||||
// (Options.SkipResolveLabels, which docker/compose never sets) — it's
|
||||
// fully functional here, unlike the genuinely Swarm-only attributes
|
||||
// above. This guards against it being re-added by mistake.
|
||||
t.Run("label_file is honored, no finding", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(tmpDir, "labels.env"), []byte("team=backend\n"), 0o644))
|
||||
content := `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
label_file:
|
||||
- ./labels.env
|
||||
`
|
||||
assert.NilError(t, os.WriteFile(filepath.Join(tmpDir, "compose.yaml"), []byte(content), 0o644))
|
||||
|
||||
service, err := NewComposeService(nil)
|
||||
assert.NilError(t, err)
|
||||
var got []api.UnsupportedAttribute
|
||||
project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{
|
||||
ConfigPaths: []string{filepath.Join(tmpDir, "compose.yaml")},
|
||||
OnUnsupportedAttribute: func(f []api.UnsupportedAttribute) { got = f },
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assertFindings(t, got, nil)
|
||||
assert.Equal(t, project.Services["web"].Labels["team"], "backend")
|
||||
})
|
||||
|
||||
t.Run("port mode host is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
ports:
|
||||
- target: 80
|
||||
published: "8080"
|
||||
protocol: tcp
|
||||
mode: host
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "ports[80/tcp].mode"}})
|
||||
})
|
||||
|
||||
// Each host-mode port is identified by its target port AND protocol: the
|
||||
// same target port can be published for both tcp and udp, and the two
|
||||
// must stay distinguishable instead of collapsing into identical lines.
|
||||
t.Run("same target port on different protocols is reported separately", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
ports:
|
||||
- target: 80
|
||||
published: "8080"
|
||||
protocol: tcp
|
||||
mode: host
|
||||
- target: 80
|
||||
published: "8080"
|
||||
protocol: udp
|
||||
mode: host
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{
|
||||
{Service: "web", Path: "ports[80/tcp].mode"},
|
||||
{Service: "web", Path: "ports[80/udp].mode"},
|
||||
})
|
||||
})
|
||||
|
||||
// compose-go's loader defaults an unset ports[].mode to "ingress" for
|
||||
// every port entry, so a plain published port must NOT produce a
|
||||
// finding.
|
||||
t.Run("port mode ingress (the loader default) produces no finding", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
`)
|
||||
assertFindings(t, got, nil)
|
||||
})
|
||||
|
||||
t.Run("volume type cluster is unsupported", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
volumes:
|
||||
- type: cluster
|
||||
source: csi-vol
|
||||
target: /data
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{{Service: "web", Path: "volumes[csi-vol].type"}})
|
||||
})
|
||||
|
||||
// Each cluster volume is identified by its source, so multiple such
|
||||
// volumes on the same service stay distinguishable.
|
||||
t.Run("multiple cluster volumes on the same service are reported separately", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
volumes:
|
||||
- type: cluster
|
||||
source: csi-vol-1
|
||||
target: /data1
|
||||
- type: cluster
|
||||
source: csi-vol-2
|
||||
target: /data2
|
||||
`)
|
||||
assertFindings(t, got, []findingKey{
|
||||
{Service: "web", Path: "volumes[csi-vol-1].type"},
|
||||
{Service: "web", Path: "volumes[csi-vol-2].type"},
|
||||
})
|
||||
})
|
||||
|
||||
// A bind mount, the most common volume shape, must never be flagged:
|
||||
// compose-go's loader defaults an unset volumes[].type to "bind" or
|
||||
// "volume" for every entry, so checking for mere presence of "type"
|
||||
// would fire on virtually every service using volumes.
|
||||
t.Run("volume type bind (the loader default) produces no finding", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
volumes:
|
||||
- /host:/container
|
||||
`)
|
||||
assertFindings(t, got, nil)
|
||||
})
|
||||
|
||||
// Migrated from the ad-hoc warning previously logged in create.go: a
|
||||
// config file reference's uid/gid/mode are silently ignored outside
|
||||
// Swarm mode. Each non-zero sub-field is reported individually, and the
|
||||
// path names the source so multiple config references stay
|
||||
// distinguishable.
|
||||
t.Run("service config file reference uid gid mode are unsupported", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
configs:
|
||||
- source: c1
|
||||
uid: "1000"
|
||||
gid: "1000"
|
||||
mode: 0o400
|
||||
configs:
|
||||
c1:
|
||||
file: ./c1.txt
|
||||
`,
|
||||
"c1.txt": "hello",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{
|
||||
{Service: "web", Path: "configs.c1.uid"},
|
||||
{Service: "web", Path: "configs.c1.gid"},
|
||||
{Service: "web", Path: "configs.c1.mode"},
|
||||
})
|
||||
})
|
||||
|
||||
// Two config references with the same violation must stay
|
||||
// distinguishable by source name, not collapse into identical findings.
|
||||
t.Run("two config file references are reported separately by source", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
configs:
|
||||
- source: c1
|
||||
uid: "1000"
|
||||
- source: c2
|
||||
uid: "1000"
|
||||
configs:
|
||||
c1:
|
||||
file: ./c1.txt
|
||||
c2:
|
||||
file: ./c2.txt
|
||||
`,
|
||||
"c1.txt": "hello",
|
||||
"c2.txt": "hello",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{
|
||||
{Service: "web", Path: "configs.c1.uid"},
|
||||
{Service: "web", Path: "configs.c2.uid"},
|
||||
})
|
||||
})
|
||||
|
||||
// Migrated from the ad-hoc warning previously logged in create.go: same
|
||||
// as above, for secret file references.
|
||||
t.Run("service secret file reference uid gid mode are unsupported", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
secrets:
|
||||
- source: s1
|
||||
uid: "1000"
|
||||
gid: "1000"
|
||||
mode: 0o400
|
||||
secrets:
|
||||
s1:
|
||||
file: ./s1.txt
|
||||
`,
|
||||
"s1.txt": "hello",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{
|
||||
{Service: "web", Path: "secrets.s1.uid"},
|
||||
{Service: "web", Path: "secrets.s1.gid"},
|
||||
{Service: "web", Path: "secrets.s1.mode"},
|
||||
})
|
||||
})
|
||||
|
||||
// A config/secret reference with no uid/gid/mode override must never be
|
||||
// flagged: only the overrides are unsupported, not the reference itself.
|
||||
t.Run("config file reference without overrides produces no finding", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
configs:
|
||||
- source: c1
|
||||
configs:
|
||||
c1:
|
||||
file: ./c1.txt
|
||||
`,
|
||||
"c1.txt": "hello",
|
||||
})
|
||||
assertFindings(t, got, nil)
|
||||
})
|
||||
|
||||
// An unrecognized depends_on condition is not tested here: the
|
||||
// compose-spec schema declares it as a closed enum, so schema.Validate
|
||||
// rejects it before this check ever runs — see
|
||||
// TestDependsOnUnknownConditionIsRejectedBySchema below.
|
||||
|
||||
t.Run("depends_on known condition produces no finding", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db:
|
||||
image: alpine
|
||||
`)
|
||||
assertFindings(t, got, nil)
|
||||
})
|
||||
|
||||
// The short-form depends_on (a plain list of service names) never
|
||||
// writes an explicit condition, so it must never be flagged either.
|
||||
t.Run("depends_on short form produces no finding", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: alpine
|
||||
`)
|
||||
assertFindings(t, got, nil)
|
||||
})
|
||||
|
||||
t.Run("project-scoped config labels is unsupported", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
configs:
|
||||
c1:
|
||||
file: ./c1.txt
|
||||
labels:
|
||||
team: backend
|
||||
`,
|
||||
"c1.txt": "hello",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{{Service: "", Path: "configs.c1.labels"}})
|
||||
})
|
||||
|
||||
// Unlike secrets, the compose-spec schema has no driver/driver_opts
|
||||
// property on a top-level configs entry (additionalProperties: false
|
||||
// rejects it at load time), so there is no equivalent case to test here
|
||||
// — a compose file setting it would fail to load entirely.
|
||||
|
||||
t.Run("project-scoped secret driver_opts is unsupported", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
secrets:
|
||||
s1:
|
||||
file: ./s1.txt
|
||||
driver_opts:
|
||||
region: eu-west
|
||||
`,
|
||||
"s1.txt": "hello",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{{Service: "", Path: "secrets.s1.driver_opts"}})
|
||||
})
|
||||
|
||||
t.Run("project-scoped secret labels is unsupported", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
secrets:
|
||||
s1:
|
||||
file: ./s1.txt
|
||||
labels:
|
||||
team: backend
|
||||
`,
|
||||
"s1.txt": "hello",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{{Service: "", Path: "secrets.s1.labels"}})
|
||||
})
|
||||
|
||||
// --- False-positive traps: these MUST produce zero findings ---
|
||||
|
||||
t.Run("deploy resources replicas and restart_policy are honored, no finding", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
replicas: 3
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 128M
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
`)
|
||||
assertFindings(t, got, nil)
|
||||
})
|
||||
|
||||
t.Run("baseline clean service produces no finding", func(t *testing.T) {
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- /host:/container
|
||||
`)
|
||||
assertFindings(t, got, nil)
|
||||
})
|
||||
|
||||
t.Run("multiple findings on the same service are all reported", func(t *testing.T) {
|
||||
got := loadWithFiles(t, map[string]string{
|
||||
"compose.yaml": `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
deploy:
|
||||
mode: replicated
|
||||
credential_spec:
|
||||
file: ./creds.json
|
||||
`,
|
||||
"creds.json": "{}",
|
||||
})
|
||||
assertFindings(t, got, []findingKey{
|
||||
{Service: "web", Path: "deploy.mode"},
|
||||
{Service: "web", Path: "credential_spec"},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnsupportedAttributes_SortedOutput asserts the reported findings are
|
||||
// sorted: project services/configs/secrets are all Go maps, so without an
|
||||
// explicit sort the order would vary between runs.
|
||||
func TestUnsupportedAttributes_SortedOutput(t *testing.T) {
|
||||
// 6 distinctly-ordered names: with only 2-3 services, random map
|
||||
// iteration has a non-negligible chance of coincidentally coming out
|
||||
// sorted, masking a regression if the production sort were ever
|
||||
// removed.
|
||||
got := loadCompose(t, `
|
||||
services:
|
||||
zeta:
|
||||
image: alpine
|
||||
deploy: {mode: replicated}
|
||||
echo:
|
||||
image: alpine
|
||||
deploy: {mode: replicated}
|
||||
delta:
|
||||
image: alpine
|
||||
deploy: {mode: replicated}
|
||||
charlie:
|
||||
image: alpine
|
||||
deploy: {mode: replicated}
|
||||
bravo:
|
||||
image: alpine
|
||||
deploy: {mode: replicated}
|
||||
alpha:
|
||||
image: alpine
|
||||
deploy: {mode: replicated}
|
||||
`)
|
||||
assert.Assert(t, slices.IsSortedFunc(got, func(a, b api.UnsupportedAttribute) int {
|
||||
if c := cmp.Compare(a.Service, b.Service); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.Path, b.Path)
|
||||
}), "%+v", got)
|
||||
}
|
||||
|
||||
// TestPresenceUnsupportedAttributes_AreKnownSchemaPaths guards
|
||||
// presenceUnsupportedAttributes against typos: every key must be a real
|
||||
// compose-spec path, or it silently does nothing (never removed from
|
||||
// supportedAttributePaths, never matched against a real finding).
|
||||
func TestPresenceUnsupportedAttributes_AreKnownSchemaPaths(t *testing.T) {
|
||||
all := schema.AttributePaths()
|
||||
for path := range presenceUnsupportedAttributes {
|
||||
assert.Assert(t, slices.Contains(all, path), "%q is not a known compose-spec attribute path", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDependsOnUnknownConditionIsRejectedBySchema documents why this
|
||||
// package has no unsupported-attribute check for depends_on.*.condition:
|
||||
// the compose-spec schema declares it as a closed enum of the 3 values this
|
||||
// runtime understands, so an unrecognized value never reaches
|
||||
// valueConditionalAttributes — it fails to load at all. waitDependency's
|
||||
// own runtime fallback in service_containers.go covers the cases that
|
||||
// bypass schema validation entirely (a project rebuilt from container
|
||||
// labels, or one written by a different Compose version).
|
||||
func TestDependsOnUnknownConditionIsRejectedBySchema(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
composeFile := filepath.Join(tmpDir, "compose.yaml")
|
||||
content := `
|
||||
services:
|
||||
web:
|
||||
image: alpine
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_ready
|
||||
db:
|
||||
image: alpine
|
||||
`
|
||||
assert.NilError(t, os.WriteFile(composeFile, []byte(content), 0o644))
|
||||
|
||||
service, err := NewComposeService(nil)
|
||||
assert.NilError(t, err)
|
||||
_, err = service.LoadProject(t.Context(), api.ProjectLoadOptions{ConfigPaths: []string{composeFile}})
|
||||
assert.ErrorContains(t, err, "condition")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
# Baseline: deploy.resources/replicas/restart_policy ARE honored outside
|
||||
# Swarm, a port without an explicit mode defaults to mode: ingress, and
|
||||
# label_file is resolved into real labels by compose-go's loader by
|
||||
# default — none of these must ever produce a warning.
|
||||
clean:
|
||||
image: alpine
|
||||
deploy:
|
||||
replicas: 2
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 128M
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
ports:
|
||||
- "9090:90"
|
||||
label_file:
|
||||
- ./labels.env
|
||||
|
||||
# Every deploy.* sub-attribute that's silently ignored outside Swarm.
|
||||
swarm-attrs:
|
||||
image: alpine
|
||||
credential_spec:
|
||||
file: ./creds.json
|
||||
deploy:
|
||||
mode: replicated
|
||||
labels:
|
||||
team: backend
|
||||
update_config:
|
||||
parallelism: 1
|
||||
failure_action: rollback
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.role==manager
|
||||
endpoint_mode: vip
|
||||
|
||||
# ports[].mode: host is Swarm-only.
|
||||
ports-demo:
|
||||
image: alpine
|
||||
ports:
|
||||
- target: 80
|
||||
published: "8080"
|
||||
mode: host
|
||||
|
||||
# volumes[].type: cluster (CSI) has no effect outside Swarm.
|
||||
cluster-vol:
|
||||
image: alpine
|
||||
volumes:
|
||||
- type: cluster
|
||||
source: my-csi-volume
|
||||
target: /data
|
||||
|
||||
# configs/secrets file-reference uid/gid/mode are ignored outside Swarm.
|
||||
file-refs:
|
||||
image: alpine
|
||||
configs:
|
||||
- source: myconfig
|
||||
uid: "1000"
|
||||
gid: "1000"
|
||||
mode: 0o440
|
||||
secrets:
|
||||
- source: mysecret
|
||||
uid: "1000"
|
||||
gid: "1000"
|
||||
mode: 0o440
|
||||
|
||||
configs:
|
||||
myconfig:
|
||||
file: ./myconfig.txt
|
||||
labels:
|
||||
team: backend # configs[].labels: reachable and flagged
|
||||
|
||||
secrets:
|
||||
mysecret:
|
||||
file: ./mysecret.txt
|
||||
driver_opts: # secrets[].driver_opts: reachable and flagged (unlike configs)
|
||||
region: eu-west
|
||||
labels:
|
||||
team: backend
|
||||
@@ -0,0 +1 @@
|
||||
{"config": "creds"}
|
||||
@@ -0,0 +1 @@
|
||||
SOME_LABEL=value
|
||||
@@ -0,0 +1 @@
|
||||
dummy config content
|
||||
@@ -0,0 +1 @@
|
||||
dummy secret content
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build e2e
|
||||
|
||||
/*
|
||||
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 e2e
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestUnsupportedAttributesWarning locks docker/compose#13150: Compose must
|
||||
// warn, at load time, when a compose-file attribute is accepted by the
|
||||
// schema but has no effect on this runtime outside Swarm mode, instead of
|
||||
// silently ignoring it. It also locks two false positives this check must
|
||||
// NOT produce: a plain published port (the loader defaults ports[].mode to
|
||||
// "ingress" for every port entry), and label_file (compose-go's loader
|
||||
// resolves it into real service labels by default — it's fully functional,
|
||||
// unlike the genuinely Swarm-only attributes here). configs[].driver_opts
|
||||
// has no effect and no warning either, since the compose-spec schema has no
|
||||
// such property on a top-level configs entry (unlike secrets[].driver_opts,
|
||||
// which is schema-valid and is flagged).
|
||||
func TestUnsupportedAttributesWarning(t *testing.T) {
|
||||
// logrus's text formatter escapes embedded quotes in a field's value, so
|
||||
// the raw output contains a literal backslash before each quote around
|
||||
// the service name (`msg="service \"x\": ..."`) — don't "clean up" these
|
||||
// backslashes, the checks stop matching if you do.
|
||||
NewScenario(t, "compose must warn about schema-valid attributes it doesn't honor outside Swarm mode, and stay silent on the ones it does honor").
|
||||
Step("every schema-valid, silently-ignored attribute is reported, and honored/unreachable attributes are not",
|
||||
ComposeCmd("config"),
|
||||
OutputContains(`service \"swarm-attrs\": deploy.mode`),
|
||||
OutputContains(`service \"swarm-attrs\": deploy.labels`),
|
||||
OutputContains(`service \"swarm-attrs\": deploy.update_config`),
|
||||
OutputContains(`service \"swarm-attrs\": deploy.rollback_config`),
|
||||
OutputContains(`service \"swarm-attrs\": deploy.placement`),
|
||||
OutputContains(`service \"swarm-attrs\": deploy.endpoint_mode`),
|
||||
OutputContains(`service \"swarm-attrs\": credential_spec`),
|
||||
OutputContains(`service \"ports-demo\": ports[80/tcp].mode`),
|
||||
OutputContains(`service \"cluster-vol\": volumes[my-csi-volume].type`),
|
||||
OutputContains(`service \"file-refs\": configs.myconfig.uid`),
|
||||
OutputContains(`service \"file-refs\": configs.myconfig.gid`),
|
||||
OutputContains(`service \"file-refs\": configs.myconfig.mode`),
|
||||
OutputContains(`service \"file-refs\": secrets.mysecret.uid`),
|
||||
OutputContains(`service \"file-refs\": secrets.mysecret.gid`),
|
||||
OutputContains(`service \"file-refs\": secrets.mysecret.mode`),
|
||||
OutputContains(`configs.myconfig.labels`),
|
||||
OutputContains(`secrets.mysecret.driver_opts`),
|
||||
OutputContains(`secrets.mysecret.labels`),
|
||||
OutputNotContains(`service \"clean\"`),
|
||||
OutputNotContains(`configs.myconfig.driver_opts`))
|
||||
}
|
||||
Reference in New Issue
Block a user