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
8 changes: 8 additions & 0 deletions pkg/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
137 changes: 137 additions & 0 deletions pkg/api/up_options.go
Original file line number Diff line number Diff line change
@@ -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
}
182 changes: 182 additions & 0 deletions pkg/api/up_options_test.go
Original file line number Diff line number Diff line change
@@ -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])
})
}
}
3 changes: 3 additions & 0 deletions pkg/compose/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading