diff --git a/pkg/api/errors.go b/pkg/api/errors.go index 7bf4d4a02c3..933cab45ae5 100644 --- a/pkg/api/errors.go +++ b/pkg/api/errors.go @@ -45,6 +45,9 @@ var ( ErrParsingFailed = errors.New("parsing failed") // ErrNoResources is returned when operation didn't selected any resource ErrNoResources = errors.New("no resources") + // ErrIncompatibleOptions is returned when a set of options that cannot be + // honored together is requested. See IncompatibleOptionsError. + ErrIncompatibleOptions = errors.New("incompatible options") ) // IsNotFoundError returns true if the unwrapped error is ErrNotFound @@ -86,3 +89,8 @@ func IsErrParsingFailed(err error) bool { func IsErrCanceled(err error) bool { return errors.Is(err, ErrCanceled) } + +// IsErrIncompatibleOptions returns true if the unwrapped error is ErrIncompatibleOptions +func IsErrIncompatibleOptions(err error) bool { + return errors.Is(err, ErrIncompatibleOptions) +} diff --git a/pkg/api/up_options.go b/pkg/api/up_options.go new file mode 100644 index 00000000000..cf9dfffd30e --- /dev/null +++ b/pkg/api/up_options.go @@ -0,0 +1,137 @@ +/* + 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 api + +import ( + "errors" + "fmt" + "slices" + "strings" +) + +// IncompatibleOptionsError reports options, or option values, that were +// requested together but cannot be honored together. It is returned, before any side effect, by +// UpOptions.Validate and therefore by Compose.Up. +// +// It matches ErrIncompatibleOptions with errors.Is, and callers needing the +// details can retrieve it with errors.As: +// +// var incompatible *api.IncompatibleOptionsError +// if errors.As(err, &incompatible) { +// log.Printf("conflict between %v: %s", incompatible.Options, incompatible.Reason) +// } +type IncompatibleOptionsError struct { + // Options names the conflicting option fields, as Go selectors relative to + // the options type that was validated (e.g. "Start.Wait"). + Options []string + // Reason explains why the options, as set, cannot be honored together. + Reason string +} + +func (e *IncompatibleOptionsError) Error() string { + return fmt.Sprintf("%s (%s): %s", ErrIncompatibleOptions, strings.Join(e.Options, ", "), e.Reason) +} + +// Unwrap makes errors.Is(err, ErrIncompatibleOptions) hold. +func (e *IncompatibleOptionsError) Unwrap() error { + return ErrIncompatibleOptions +} + +func incompatible(reason string, options ...string) error { + return &IncompatibleOptionsError{Options: options, Reason: reason} +} + +// Validate checks that the options requested for Up can be honored together. +// It is a pure function, called by Up before it does anything, and can also be +// called by integrators to fail early. It returns nil, or an error matching +// ErrIncompatibleOptions describing every conflict found. +// +// Up has two modes, selected by Start.Attach: detached (nil) returns once the +// containers are started, interactive (set) keeps running a foreground session. +// Validate only checks the combinations listed below: Wait with Attach, and +// OnExit, ExitCodeFrom or Watch without Attach. Other mode-specific fields +// (e.g. Services, AttachTo, NavigationMenu) are not checked and may be ignored +// in the mode they do not apply to. +// +// With a single conflict the error is an *IncompatibleOptionsError. With +// several, the conflicts are reported together as an error joined with +// errors.Join: errors.Is(err, ErrIncompatibleOptions) still holds, but +// errors.As to *IncompatibleOptionsError only yields the first conflict. To +// inspect all of them, walk the joined error through its Unwrap() []error +// method. +// +// The compose CLI refuses the same combinations, with messages naming its +// flags; Validate is the safety net for programs using the library directly. +func (o UpOptions) Validate() error { + var errs []error + errs = append(errs, validateWaitIsDetached(o.Start)...) + errs = append(errs, validateDetachedHasNoSession(o.Start)...) + switch len(errs) { + case 0: + return nil + case 1: + return errs[0] + default: + return errors.Join(errs...) + } +} + +// validateWaitIsDetached: Wait blocks until the services are running|healthy +// and then returns, which only happens in detached mode. In an interactive +// session that check would be silently dropped. +// CLI: --wait implies --detach and is incompatible with --attach and +// --attach-dependencies. +func validateWaitIsDetached(s StartOptions) []error { + if s.Wait && s.Attach != nil { + return []error{incompatible("Wait returns once services are running or healthy, which is detached mode, while Attach starts an interactive session", + "Start.Wait", "Start.Attach")} + } + return nil +} + +// validateDetachedHasNoSession: the cascade policy, the exit code selection +// and watch are all features of the interactive session, they have no effect +// when Attach is nil. Every such option set is reported. When Wait is set, it +// is the reason why the session cannot be started, so it is part of the conflict. +// CLI: --detach is incompatible with --abort-on-container-exit, +// --abort-on-container-failure, --exit-code-from (which implies +// --abort-on-container-exit) and --watch. +func validateDetachedHasNoSession(s StartOptions) []error { + if s.Attach != nil { + return nil + } + reason := "Attach is nil (detached mode), so no interactive session runs to honor " + options := []string{"Start.Attach"} + if s.Wait { + reason = "Wait implies detached mode and Attach is nil, so no interactive session runs to honor " + options = []string{"Start.Wait", "Start.Attach"} + } + conflict := func(field string) error { + return incompatible(reason+field, append(slices.Clone(options), "Start."+field)...) + } + var errs []error + if s.OnExit != CascadeIgnore { + errs = append(errs, conflict("OnExit")) + } + if s.ExitCodeFrom != "" { + errs = append(errs, conflict("ExitCodeFrom")) + } + if s.Watch { + errs = append(errs, conflict("Watch")) + } + return errs +} diff --git a/pkg/api/up_options_test.go b/pkg/api/up_options_test.go new file mode 100644 index 00000000000..d7a4741e75d --- /dev/null +++ b/pkg/api/up_options_test.go @@ -0,0 +1,182 @@ +/* + 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 api + +import ( + "errors" + "fmt" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +type nopConsumer struct{} + +func (nopConsumer) Log(string, string) {} +func (nopConsumer) Err(string, string) {} +func (nopConsumer) Status(string, string) {} + +func TestUpOptionsValidate(t *testing.T) { + attached := nopConsumer{} + + tests := []struct { + name string + start StartOptions + // conflict lists the expected conflicting options, empty when valid + conflict []string + }{ + // valid combinations, closest to each rule + {name: "zero value is detached and valid", start: StartOptions{}}, + {name: "detached with services and attach targets", start: StartOptions{Services: []string{"web"}, AttachTo: []string{"web"}}}, + {name: "detached wait", start: StartOptions{Wait: true}}, + {name: "detached wait with timeout", start: StartOptions{Wait: true, WaitTimeout: time.Minute}}, + {name: "detached wait timeout without wait", start: StartOptions{WaitTimeout: time.Minute}}, + {name: "interactive", start: StartOptions{Attach: attached}}, + {name: "interactive with menu", start: StartOptions{Attach: attached, NavigationMenu: true}}, + {name: "interactive cascade stop", start: StartOptions{Attach: attached, OnExit: CascadeStop}}, + {name: "interactive cascade fail", start: StartOptions{Attach: attached, OnExit: CascadeFail}}, + {name: "interactive exit code from", start: StartOptions{Attach: attached, ExitCodeFrom: "web"}}, + {name: "interactive exit code from with cascade", start: StartOptions{Attach: attached, OnExit: CascadeStop, ExitCodeFrom: "web"}}, + {name: "interactive watch", start: StartOptions{Attach: attached, Watch: true}}, + {name: "interactive everything", start: StartOptions{Attach: attached, AttachTo: []string{"web"}, OnExit: CascadeFail, ExitCodeFrom: "web", Watch: true, NavigationMenu: true}}, + + // wait is detached + {name: "wait with attach", start: StartOptions{Wait: true, Attach: attached}, conflict: []string{"Start.Wait", "Start.Attach"}}, + {name: "wait with attach and timeout", start: StartOptions{Wait: true, WaitTimeout: time.Second, Attach: attached}, conflict: []string{"Start.Wait", "Start.Attach"}}, + + // session-only options require a session + {name: "detached cascade stop", start: StartOptions{OnExit: CascadeStop}, conflict: []string{"Start.Attach", "Start.OnExit"}}, + {name: "detached cascade fail", start: StartOptions{OnExit: CascadeFail}, conflict: []string{"Start.Attach", "Start.OnExit"}}, + {name: "detached exit code from", start: StartOptions{ExitCodeFrom: "web"}, conflict: []string{"Start.Attach", "Start.ExitCodeFrom"}}, + {name: "detached watch", start: StartOptions{Watch: true}, conflict: []string{"Start.Attach", "Start.Watch"}}, + {name: "wait with cascade", start: StartOptions{Wait: true, OnExit: CascadeStop}, conflict: []string{"Start.Wait", "Start.Attach", "Start.OnExit"}}, + {name: "wait with exit code from", start: StartOptions{Wait: true, ExitCodeFrom: "web"}, conflict: []string{"Start.Wait", "Start.Attach", "Start.ExitCodeFrom"}}, + {name: "wait with watch", start: StartOptions{Wait: true, Watch: true}, conflict: []string{"Start.Wait", "Start.Attach", "Start.Watch"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := UpOptions{Start: tt.start}.Validate() + if tt.conflict == nil { + assert.NilError(t, err) + return + } + assert.ErrorIs(t, err, ErrIncompatibleOptions) + assert.Assert(t, IsErrIncompatibleOptions(err)) + + var incompatible *IncompatibleOptionsError + assert.Assert(t, errors.As(err, &incompatible), "expected *IncompatibleOptionsError, got %T", err) + assert.DeepEqual(t, incompatible.Options, tt.conflict) + assert.Assert(t, incompatible.Reason != "") + }) + } +} + +func TestUpOptionsValidateIgnoresCreateOptions(t *testing.T) { + // Create options are not subject to validation: the compose CLI rules + // about recreation or build only have a meaning at flag level. + err := UpOptions{Create: CreateOptions{Recreate: RecreateNever, RecreateDependencies: RecreateForce}}.Validate() + assert.NilError(t, err) +} + +func TestIncompatibleOptionsError(t *testing.T) { + err := fmt.Errorf("wrapped: %w", &IncompatibleOptionsError{ + Options: []string{"A", "B"}, + Reason: "because", + }) + assert.ErrorIs(t, err, ErrIncompatibleOptions) + assert.Assert(t, IsErrIncompatibleOptions(err)) + assert.Assert(t, !IsErrIncompatibleOptions(ErrNotFound)) + var incompatible *IncompatibleOptionsError + assert.Assert(t, errors.As(err, &incompatible)) + assert.Equal(t, incompatible.Error(), "incompatible options (A, B): because") +} + +// conflictsOf returns the options of each conflict reported by err, in order. +func conflictsOf(t *testing.T, err error) [][]string { + t.Helper() + errs := []error{err} + if joined, ok := err.(interface{ Unwrap() []error }); ok { + errs = joined.Unwrap() + } + var all [][]string + for _, e := range errs { + var incompatible *IncompatibleOptionsError + assert.Assert(t, errors.As(e, &incompatible), "expected *IncompatibleOptionsError, got %T", e) + all = append(all, incompatible.Options) + } + return all +} + +func TestUpOptionsValidateReportsAllConflicts(t *testing.T) { + attached := nopConsumer{} + + tests := []struct { + name string + start StartOptions + want [][]string + }{ + { + name: "two session options", + start: StartOptions{OnExit: CascadeStop, ExitCodeFrom: "web"}, + want: [][]string{{"Start.Attach", "Start.OnExit"}, {"Start.Attach", "Start.ExitCodeFrom"}}, + }, + { + name: "exit code from and watch", + start: StartOptions{ExitCodeFrom: "web", Watch: true}, + want: [][]string{{"Start.Attach", "Start.ExitCodeFrom"}, {"Start.Attach", "Start.Watch"}}, + }, + { + name: "all session options", + start: StartOptions{OnExit: CascadeFail, ExitCodeFrom: "web", Watch: true}, + want: [][]string{{"Start.Attach", "Start.OnExit"}, {"Start.Attach", "Start.ExitCodeFrom"}, {"Start.Attach", "Start.Watch"}}, + }, + { + name: "wait with all session options", + start: StartOptions{Wait: true, OnExit: CascadeStop, ExitCodeFrom: "web", Watch: true}, + want: [][]string{ + {"Start.Wait", "Start.Attach", "Start.OnExit"}, + {"Start.Wait", "Start.Attach", "Start.ExitCodeFrom"}, + {"Start.Wait", "Start.Attach", "Start.Watch"}, + }, + }, + { + name: "single session option is not wrapped", + start: StartOptions{Watch: true}, + want: [][]string{{"Start.Attach", "Start.Watch"}}, + }, + { + // the session options are fine once Attach is set, only Wait conflicts + name: "wait with attach and session options", + start: StartOptions{Wait: true, Attach: attached, OnExit: CascadeStop, ExitCodeFrom: "web", Watch: true}, + want: [][]string{{"Start.Wait", "Start.Attach"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := UpOptions{Start: tt.start}.Validate() + assert.ErrorIs(t, err, ErrIncompatibleOptions) + assert.Assert(t, IsErrIncompatibleOptions(err)) + assert.DeepEqual(t, conflictsOf(t, err), tt.want) + + // errors.As on the combined error yields the first conflict + var incompatible *IncompatibleOptionsError + assert.Assert(t, errors.As(err, &incompatible)) + assert.DeepEqual(t, incompatible.Options, tt.want[0]) + }) + } +} diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 2c7998a46b4..15e40ccc3fc 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -43,6 +43,9 @@ import ( ) func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { + if err := options.Validate(); err != nil { + return err + } err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { err := s.create(ctx, project, options.Create) if err != nil { diff --git a/pkg/compose/up_options_test.go b/pkg/compose/up_options_test.go new file mode 100644 index 00000000000..ade5f3f7822 --- /dev/null +++ b/pkg/compose/up_options_test.go @@ -0,0 +1,73 @@ +/* + 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 ( + "errors" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" +) + +// Up must refuse incompatible options before any side effect: the strict mock +// client fails the test on any engine call, and no progress event may be +// emitted either. +func TestUpRejectsIncompatibleOptionsBeforeAnySideEffect(t *testing.T) { + tests := []struct { + name string + options api.UpOptions + want []string + }{ + { + name: "wait with attach", + options: api.UpOptions{Start: api.StartOptions{Wait: true, Attach: &testLogConsumer{}}}, + want: []string{"Start.Wait", "Start.Attach"}, + }, + { + name: "detached with cascade", + options: api.UpOptions{Start: api.StartOptions{OnExit: api.CascadeStop}}, + want: []string{"Start.Attach", "Start.OnExit"}, + }, + { + name: "detached with exit code from", + options: api.UpOptions{Start: api.StartOptions{ExitCodeFrom: "web"}}, + want: []string{"Start.Attach", "Start.ExitCodeFrom"}, + }, + { + name: "detached with watch", + options: api.UpOptions{Start: api.StartOptions{Watch: true}}, + want: []string{"Start.Attach", "Start.Watch"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _, rec := newStartTestService(t) + project := &types.Project{Name: "prj", Services: types.Services{"web": {Name: "web"}}} + + err := svc.Up(t.Context(), project, tt.options) + + assert.ErrorIs(t, err, api.ErrIncompatibleOptions) + var incompatible *api.IncompatibleOptionsError + assert.Assert(t, errors.As(err, &incompatible), "expected *api.IncompatibleOptionsError, got %T", err) + assert.DeepEqual(t, incompatible.Options, tt.want) + assert.Equal(t, len(rec.summary()), 0, "no progress event expected, got %v", rec.summary()) + }) + } +}