Merge pull request #4939 from Benehiko/prompt-termination

feat: standardize error for prompt
This commit is contained in:
Sebastiaan van Stijn 2024-04-02 19:09:12 +02:00 committed by GitHub
commit 9ca30bd2ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 425 additions and 165 deletions

View File

@ -2,6 +2,7 @@ package builder
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
@ -10,6 +11,7 @@ import (
"github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/errdefs"
units "github.com/docker/go-units" units "github.com/docker/go-units"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -67,9 +69,13 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
warning = allCacheWarning warning = allCacheWarning
} }
if !options.force { if !options.force {
if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning); !r || err != nil { r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return 0, "", err return 0, "", err
} }
if !r {
return 0, "", errdefs.Cancelled(errors.New("builder prune has been cancelled"))
}
} }
report, err := dockerCli.Client().BuildCachePrune(ctx, types.BuildCachePruneOptions{ report, err := dockerCli.Client().BuildCachePrune(ctx, types.BuildCachePruneOptions{

View File

@ -5,10 +5,8 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"gotest.tools/v3/assert"
) )
func TestBuilderPromptTermination(t *testing.T) { func TestBuilderPromptTermination(t *testing.T) {
@ -21,8 +19,5 @@ func TestBuilderPromptTermination(t *testing.T) {
}, },
}) })
cmd := NewPruneCommand(cli) cmd := NewPruneCommand(cli)
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
} }

View File

@ -8,7 +8,9 @@ import (
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/docker/errdefs"
units "github.com/docker/go-units" units "github.com/docker/go-units"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -54,9 +56,13 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
pruneFilters := command.PruneFilters(dockerCli, options.filter.Value()) pruneFilters := command.PruneFilters(dockerCli, options.filter.Value())
if !options.force { if !options.force {
if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning); !r || err != nil { r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return 0, "", err return 0, "", err
} }
if !r {
return 0, "", errdefs.Cancelled(errors.New("container prune has been cancelled"))
}
} }
report, err := dockerCli.Client().ContainersPrune(ctx, pruneFilters) report, err := dockerCli.Client().ContainersPrune(ctx, pruneFilters)

View File

@ -4,12 +4,10 @@ import (
"context" "context"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/pkg/errors" "github.com/pkg/errors"
"gotest.tools/v3/assert"
) )
func TestContainerPrunePromptTermination(t *testing.T) { func TestContainerPrunePromptTermination(t *testing.T) {
@ -22,8 +20,5 @@ func TestContainerPrunePromptTermination(t *testing.T) {
}, },
}) })
cmd := NewPruneCommand(cli) cmd := NewPruneCommand(cli)
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
} }

View File

@ -10,7 +10,9 @@ import (
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/docker/errdefs"
units "github.com/docker/go-units" units "github.com/docker/go-units"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -68,9 +70,13 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
warning = allImageWarning warning = allImageWarning
} }
if !options.force { if !options.force {
if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning); !r || err != nil { r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return 0, "", err return 0, "", err
} }
if !r {
return 0, "", errdefs.Cancelled(errors.New("image prune has been cancelled"))
}
} }
report, err := dockerCli.Client().ImagesPrune(ctx, pruneFilters) report, err := dockerCli.Client().ImagesPrune(ctx, pruneFilters)

View File

@ -4,9 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"strings"
"testing" "testing"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
@ -94,13 +95,18 @@ func TestNewPruneCommandSuccess(t *testing.T) {
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
cli := test.NewFakeCli(&fakeClient{imagesPruneFunc: tc.imagesPruneFunc}) t.Run(tc.name, func(t *testing.T) {
cmd := NewPruneCommand(cli) cli := test.NewFakeCli(&fakeClient{imagesPruneFunc: tc.imagesPruneFunc})
cmd.SetOut(io.Discard) // when prompted, answer "Y" to confirm the prune.
cmd.SetArgs(tc.args) // will not be prompted if --force is used.
err := cmd.Execute() cli.SetIn(streams.NewIn(io.NopCloser(strings.NewReader("Y\n"))))
assert.NilError(t, err) cmd := NewPruneCommand(cli)
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("prune-command-success.%s.golden", tc.name)) cmd.SetOut(io.Discard)
cmd.SetArgs(tc.args)
err := cmd.Execute()
assert.NilError(t, err)
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("prune-command-success.%s.golden", tc.name))
})
} }
} }
@ -114,8 +120,5 @@ func TestPrunePromptTermination(t *testing.T) {
}, },
}) })
cmd := NewPruneCommand(cli) cmd := NewPruneCommand(cli)
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
} }

View File

@ -7,6 +7,8 @@ import (
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -50,9 +52,13 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
pruneFilters := command.PruneFilters(dockerCli, options.filter.Value()) pruneFilters := command.PruneFilters(dockerCli, options.filter.Value())
if !options.force { if !options.force {
if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning); !r || err != nil { r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
if err != nil {
return "", err return "", err
} }
if !r {
return "", errdefs.Cancelled(errors.New("network prune cancelled has been cancelled"))
}
} }
report, err := dockerCli.Client().NetworksPrune(ctx, pruneFilters) report, err := dockerCli.Client().NetworksPrune(ctx, pruneFilters)

View File

@ -4,12 +4,10 @@ import (
"context" "context"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/pkg/errors" "github.com/pkg/errors"
"gotest.tools/v3/assert"
) )
func TestNetworkPrunePromptTermination(t *testing.T) { func TestNetworkPrunePromptTermination(t *testing.T) {
@ -22,8 +20,5 @@ func TestNetworkPrunePromptTermination(t *testing.T) {
}, },
}) })
cmd := NewPruneCommand(cli) cmd := NewPruneCommand(cli)
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
} }

View File

@ -5,7 +5,6 @@ import (
"io" "io"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/errdefs" "github.com/docker/docker/errdefs"
@ -115,8 +114,5 @@ func TestNetworkRemovePromptTermination(t *testing.T) {
}) })
cmd := newRemoveCommand(cli) cmd := newRemoveCommand(cli)
cmd.SetArgs([]string{"existing-network"}) cmd.SetArgs([]string{"existing-network"})
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
} }

View File

@ -1,2 +1,2 @@
Upgrading plugin foo/bar from localhost:5000/foo/bar:v0.1.0 to localhost:5000/foo/bar:v1.0.0 Upgrading plugin foo/bar from localhost:5000/foo/bar:v0.1.0 to localhost:5000/foo/bar:v1.0.0
Plugin images do not match, are you sure? [y/N] Plugin images do not match, are you sure? [y/N]

View File

@ -8,6 +8,7 @@ import (
"github.com/distribution/reference" "github.com/distribution/reference"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/docker/errdefs"
"github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/jsonmessage"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -63,11 +64,12 @@ func runUpgrade(ctx context.Context, dockerCli command.Cli, opts pluginOptions)
fmt.Fprintf(dockerCli.Out(), "Upgrading plugin %s from %s to %s\n", p.Name, reference.FamiliarString(old), reference.FamiliarString(remote)) fmt.Fprintf(dockerCli.Out(), "Upgrading plugin %s from %s to %s\n", p.Name, reference.FamiliarString(old), reference.FamiliarString(remote))
if !opts.skipRemoteCheck && remote.String() != old.String() { if !opts.skipRemoteCheck && remote.String() != old.String() {
if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), "Plugin images do not match, are you sure?"); !r || err != nil { r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), "Plugin images do not match, are you sure?")
if err != nil { if err != nil {
return errors.Wrap(err, "canceling upgrade request") return err
} }
return errors.New("canceling upgrade request") if !r {
return errdefs.Cancelled(errors.New("plugin upgrade has been cancelled"))
} }
} }

View File

@ -5,11 +5,9 @@ import (
"io" "io"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/pkg/errors" "github.com/pkg/errors"
"gotest.tools/v3/assert"
"gotest.tools/v3/golden" "gotest.tools/v3/golden"
) )
@ -34,9 +32,6 @@ func TestUpgradePromptTermination(t *testing.T) {
// need to set a remote address that does not match the plugin // need to set a remote address that does not match the plugin
// reference sent by the `pluginInspectFunc` // reference sent by the `pluginInspectFunc`
cmd.SetArgs([]string{"foo/bar", "localhost:5000/foo/bar:v1.0.0"}) cmd.SetArgs([]string{"foo/bar", "localhost:5000/foo/bar:v1.0.0"})
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
golden.Assert(t, cli.OutBuffer().String(), "plugin-upgrade-terminate.golden") golden.Assert(t, cli.OutBuffer().String(), "plugin-upgrade-terminate.golden")
} }

View File

@ -17,8 +17,10 @@ import (
"github.com/docker/cli/cli/command/volume" "github.com/docker/cli/cli/command/volume"
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/docker/api/types/versions" "github.com/docker/docker/api/types/versions"
"github.com/docker/docker/errdefs"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/fvbommel/sortorder" "github.com/fvbommel/sortorder"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -75,9 +77,13 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
return fmt.Errorf(`ERROR: The "until" filter is not supported with "--volumes"`) return fmt.Errorf(`ERROR: The "until" filter is not supported with "--volumes"`)
} }
if !options.force { if !options.force {
if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), confirmationMessage(dockerCli, options)); !r || err != nil { r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), confirmationMessage(dockerCli, options))
if err != nil {
return err return err
} }
if !r {
return errdefs.Cancelled(errors.New("system prune has been cancelled"))
}
} }
pruneFuncs := []func(ctx context.Context, dockerCli command.Cli, all bool, filter opts.FilterOpt) (uint64, string, error){ pruneFuncs := []func(ctx context.Context, dockerCli command.Cli, all bool, filter opts.FilterOpt) (uint64, string, error){
container.RunPrune, container.RunPrune,

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@ -18,7 +17,7 @@ func TestPrunePromptPre131DoesNotIncludeBuildCache(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{version: "1.30"}) cli := test.NewFakeCli(&fakeClient{version: "1.30"})
cmd := newPruneCommand(cli) cmd := newPruneCommand(cli)
cmd.SetArgs([]string{}) cmd.SetArgs([]string{})
assert.NilError(t, cmd.Execute()) assert.ErrorContains(t, cmd.Execute(), "system prune has been cancelled")
expected := `WARNING! This will remove: expected := `WARNING! This will remove:
- all stopped containers - all stopped containers
- all networks not used by at least one container - all networks not used by at least one container
@ -36,7 +35,7 @@ func TestPrunePromptFilters(t *testing.T) {
cmd := newPruneCommand(cli) cmd := newPruneCommand(cli)
cmd.SetArgs([]string{"--filter", "until=24h", "--filter", "label=hello-world", "--filter", "label!=foo=bar", "--filter", "label=bar=baz"}) cmd.SetArgs([]string{"--filter", "until=24h", "--filter", "label=hello-world", "--filter", "label!=foo=bar", "--filter", "label=bar=baz"})
assert.NilError(t, cmd.Execute()) assert.ErrorContains(t, cmd.Execute(), "system prune has been cancelled")
expected := `WARNING! This will remove: expected := `WARNING! This will remove:
- all stopped containers - all stopped containers
- all networks not used by at least one container - all networks not used by at least one container
@ -69,8 +68,5 @@ func TestSystemPrunePromptTermination(t *testing.T) {
}) })
cmd := newPruneCommand(cli) cmd := newPruneCommand(cli)
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
} }

View File

@ -8,6 +8,7 @@ import (
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/image" "github.com/docker/cli/cli/command/image"
"github.com/docker/cli/cli/trust" "github.com/docker/cli/cli/trust"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/theupdateframework/notary/client" "github.com/theupdateframework/notary/client"
@ -45,12 +46,10 @@ func revokeTrust(ctx context.Context, dockerCLI command.Cli, remote string, opti
if imgRefAndAuth.Tag() == "" && !options.forceYes { if imgRefAndAuth.Tag() == "" && !options.forceYes {
deleteRemote, err := command.PromptForConfirmation(ctx, dockerCLI.In(), dockerCLI.Out(), fmt.Sprintf("Please confirm you would like to delete all signature data for %s?", remote)) deleteRemote, err := command.PromptForConfirmation(ctx, dockerCLI.In(), dockerCLI.Out(), fmt.Sprintf("Please confirm you would like to delete all signature data for %s?", remote))
if err != nil { if err != nil {
fmt.Fprintf(dockerCLI.Out(), "\nAborting action.\n") return err
return errors.Wrap(err, "aborting action")
} }
if !deleteRemote { if !deleteRemote {
fmt.Fprintf(dockerCLI.Out(), "\nAborting action.\n") return errdefs.Cancelled(errors.New("trust revoke has been cancelled"))
return nil
} }
} }

View File

@ -5,7 +5,6 @@ import (
"io" "io"
"testing" "testing"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/trust" "github.com/docker/cli/cli/trust"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
"github.com/docker/cli/internal/test/notary" "github.com/docker/cli/internal/test/notary"
@ -58,6 +57,8 @@ func TestTrustRevokeCommandErrors(t *testing.T) {
} }
func TestTrustRevokeCommand(t *testing.T) { func TestTrustRevokeCommand(t *testing.T) {
revokeCancelledError := "trust revoke has been cancelled"
testCases := []struct { testCases := []struct {
doc string doc string
notaryRepository func(trust.ImageRefAndAuth, []string) (client.Repository, error) notaryRepository func(trust.ImageRefAndAuth, []string) (client.Repository, error)
@ -69,7 +70,8 @@ func TestTrustRevokeCommand(t *testing.T) {
doc: "OfflineErrors_Confirm", doc: "OfflineErrors_Confirm",
notaryRepository: notary.GetOfflineNotaryRepository, notaryRepository: notary.GetOfflineNotaryRepository,
args: []string{"reg-name.io/image"}, args: []string{"reg-name.io/image"},
expectedMessage: "Please confirm you would like to delete all signature data for reg-name.io/image? [y/N] \nAborting action.", expectedMessage: "Please confirm you would like to delete all signature data for reg-name.io/image? [y/N] ",
expectedErr: revokeCancelledError,
}, },
{ {
doc: "OfflineErrors_Offline", doc: "OfflineErrors_Offline",
@ -87,7 +89,8 @@ func TestTrustRevokeCommand(t *testing.T) {
doc: "UninitializedErrors_Confirm", doc: "UninitializedErrors_Confirm",
notaryRepository: notary.GetUninitializedNotaryRepository, notaryRepository: notary.GetUninitializedNotaryRepository,
args: []string{"reg-name.io/image"}, args: []string{"reg-name.io/image"},
expectedMessage: "Please confirm you would like to delete all signature data for reg-name.io/image? [y/N] \nAborting action.", expectedMessage: "Please confirm you would like to delete all signature data for reg-name.io/image? [y/N] ",
expectedErr: revokeCancelledError,
}, },
{ {
doc: "UninitializedErrors_NoTrustData", doc: "UninitializedErrors_NoTrustData",
@ -105,7 +108,8 @@ func TestTrustRevokeCommand(t *testing.T) {
doc: "EmptyNotaryRepo_Confirm", doc: "EmptyNotaryRepo_Confirm",
notaryRepository: notary.GetEmptyTargetsNotaryRepository, notaryRepository: notary.GetEmptyTargetsNotaryRepository,
args: []string{"reg-name.io/image"}, args: []string{"reg-name.io/image"},
expectedMessage: "Please confirm you would like to delete all signature data for reg-name.io/image? [y/N] \nAborting action.", expectedMessage: "Please confirm you would like to delete all signature data for reg-name.io/image? [y/N] ",
expectedErr: revokeCancelledError,
}, },
{ {
doc: "EmptyNotaryRepo_NoSignedTags", doc: "EmptyNotaryRepo_NoSignedTags",
@ -123,7 +127,8 @@ func TestTrustRevokeCommand(t *testing.T) {
doc: "AllSigConfirmation", doc: "AllSigConfirmation",
notaryRepository: notary.GetEmptyTargetsNotaryRepository, notaryRepository: notary.GetEmptyTargetsNotaryRepository,
args: []string{"alpine"}, args: []string{"alpine"},
expectedMessage: "Please confirm you would like to delete all signature data for alpine? [y/N] \nAborting action.", expectedMessage: "Please confirm you would like to delete all signature data for alpine? [y/N] ",
expectedErr: revokeCancelledError,
}, },
} }
@ -136,9 +141,9 @@ func TestTrustRevokeCommand(t *testing.T) {
cmd.SetOut(io.Discard) cmd.SetOut(io.Discard)
if tc.expectedErr != "" { if tc.expectedErr != "" {
assert.ErrorContains(t, cmd.Execute(), tc.expectedErr) assert.ErrorContains(t, cmd.Execute(), tc.expectedErr)
return } else {
assert.NilError(t, cmd.Execute())
} }
assert.NilError(t, cmd.Execute())
assert.Check(t, is.Contains(cli.OutBuffer().String(), tc.expectedMessage)) assert.Check(t, is.Contains(cli.OutBuffer().String(), tc.expectedMessage))
}) })
} }
@ -159,10 +164,6 @@ func TestRevokeTrustPromptTermination(t *testing.T) {
cli := test.NewFakeCli(&fakeClient{}) cli := test.NewFakeCli(&fakeClient{})
cmd := newRevokeCommand(cli) cmd := newRevokeCommand(cli)
cmd.SetArgs([]string{"example/trust-demo"}) cmd.SetArgs([]string{"example/trust-demo"})
test.TerminatePrompt(ctx, t, cmd, cli, func(t *testing.T, err error) { test.TerminatePrompt(ctx, t, cmd, cli)
t.Helper()
assert.ErrorIs(t, err, command.ErrPromptTerminated)
})
assert.Equal(t, cli.ErrBuffer().String(), "")
golden.Assert(t, cli.OutBuffer().String(), "trust-revoke-prompt-termination.golden") golden.Assert(t, cli.OutBuffer().String(), "trust-revoke-prompt-termination.golden")
} }

View File

@ -132,10 +132,12 @@ func removeSingleSigner(ctx context.Context, dockerCLI command.Cli, repoName, si
} }
ok, err := maybePromptForSignerRemoval(ctx, dockerCLI, repoName, signerName, isLastSigner, forceYes) ok, err := maybePromptForSignerRemoval(ctx, dockerCLI, repoName, signerName, isLastSigner, forceYes)
if err != nil || !ok { if err != nil {
fmt.Fprintf(dockerCLI.Out(), "\nAborting action.\n")
return false, err return false, err
} }
if !ok {
return false, nil
}
if err := notaryRepo.RemoveDelegationKeys(releasesRoleTUFName, role.KeyIDs); err != nil { if err := notaryRepo.RemoveDelegationKeys(releasesRoleTUFName, role.KeyIDs); err != nil {
return false, err return false, err

View File

@ -1,2 +1 @@
Please confirm you would like to delete all signature data for example/trust-demo? [y/N] Please confirm you would like to delete all signature data for example/trust-demo? [y/N]
Aborting action.

View File

@ -19,6 +19,7 @@ import (
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
mounttypes "github.com/docker/docker/api/types/mount" mounttypes "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/versions" "github.com/docker/docker/api/types/versions"
"github.com/docker/docker/errdefs"
"github.com/moby/sys/sequential" "github.com/moby/sys/sequential"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/pflag" "github.com/spf13/pflag"
@ -75,9 +76,7 @@ func PrettyPrint(i any) string {
} }
} }
type PromptError error var ErrPromptTerminated = errdefs.Cancelled(errors.New("prompt terminated"))
var ErrPromptTerminated = PromptError(errors.New("prompt terminated"))
// PromptForConfirmation requests and checks confirmation from the user. // PromptForConfirmation requests and checks confirmation from the user.
// This will display the provided message followed by ' [y/N] '. If the user // This will display the provided message followed by ' [y/N] '. If the user
@ -123,6 +122,8 @@ func PromptForConfirmation(ctx context.Context, ins io.Reader, outs io.Writer, m
select { select {
case <-notifyCtx.Done(): case <-notifyCtx.Done():
// print a newline on termination
_, _ = fmt.Fprintln(outs, "")
return false, ErrPromptTerminated return false, ErrPromptTerminated
case r := <-result: case r := <-result:
return r, nil return r, nil

View File

@ -32,10 +32,6 @@ func NewPruneCommand(dockerCli command.Cli) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
spaceReclaimed, output, err := runPrune(cmd.Context(), dockerCli, options) spaceReclaimed, output, err := runPrune(cmd.Context(), dockerCli, options)
if err != nil { if err != nil {
if errdefs.IsCancelled(err) {
fmt.Fprintln(dockerCli.Out(), output)
return nil
}
return err return err
} }
if output != "" { if output != "" {
@ -81,8 +77,12 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
warning = allVolumesWarning warning = allVolumesWarning
} }
if !options.force { if !options.force {
if r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning); !r || err != nil { r, err := command.PromptForConfirmation(ctx, dockerCli.In(), dockerCli.Out(), warning)
return 0, "", errdefs.Cancelled(errors.New("user cancelled operation")) if err != nil {
return 0, "", err
}
if !r {
return 0, "", errdefs.Cancelled(errors.New("volume prune has been cancelled"))
} }
} }

View File

@ -155,6 +155,7 @@ func TestVolumePrunePromptYes(t *testing.T) {
cli.SetIn(streams.NewIn(io.NopCloser(strings.NewReader(input)))) cli.SetIn(streams.NewIn(io.NopCloser(strings.NewReader(input))))
cmd := NewPruneCommand(cli) cmd := NewPruneCommand(cli)
cmd.SetArgs([]string{})
assert.NilError(t, cmd.Execute()) assert.NilError(t, cmd.Execute())
golden.Assert(t, cli.OutBuffer().String(), "volume-prune-yes.golden") golden.Assert(t, cli.OutBuffer().String(), "volume-prune-yes.golden")
} }
@ -171,7 +172,8 @@ func TestVolumePrunePromptNo(t *testing.T) {
cli.SetIn(streams.NewIn(io.NopCloser(strings.NewReader(input)))) cli.SetIn(streams.NewIn(io.NopCloser(strings.NewReader(input))))
cmd := NewPruneCommand(cli) cmd := NewPruneCommand(cli)
assert.NilError(t, cmd.Execute()) cmd.SetArgs([]string{})
assert.ErrorContains(t, cmd.Execute(), "volume prune has been cancelled")
golden.Assert(t, cli.OutBuffer().String(), "volume-prune-no.golden") golden.Assert(t, cli.OutBuffer().String(), "volume-prune-no.golden")
} }
} }
@ -196,7 +198,7 @@ func TestVolumePrunePromptTerminate(t *testing.T) {
}) })
cmd := NewPruneCommand(cli) cmd := NewPruneCommand(cli)
test.TerminatePrompt(ctx, t, cmd, cli, nil) cmd.SetArgs([]string{})
test.TerminatePrompt(ctx, t, cmd, cli)
golden.Assert(t, cli.OutBuffer().String(), "volume-prune-terminate.golden") golden.Assert(t, cli.OutBuffer().String(), "volume-prune-terminate.golden")
} }

View File

@ -1,2 +1,2 @@
WARNING! This will remove anonymous local volumes not used by at least one container. WARNING! This will remove anonymous local volumes not used by at least one container.
Are you sure you want to continue? [y/N] Are you sure you want to continue? [y/N]

View File

@ -18,6 +18,7 @@ import (
"github.com/docker/cli/cli/version" "github.com/docker/cli/cli/version"
platformsignals "github.com/docker/cli/cmd/docker/internal/signals" platformsignals "github.com/docker/cli/cmd/docker/internal/signals"
"github.com/docker/docker/api/types/versions" "github.com/docker/docker/api/types/versions"
"github.com/docker/docker/errdefs"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -46,6 +47,9 @@ func main() {
} }
os.Exit(sterr.StatusCode) os.Exit(sterr.StatusCode)
} }
if errdefs.IsCancelled(err) {
os.Exit(0)
}
fmt.Fprintln(dockerCli.Err(), err) fmt.Fprintln(dockerCli.Err(), err)
os.Exit(1) os.Exit(1)
} }

View File

@ -1,14 +1,25 @@
package global package global
import ( import (
"bufio"
"bytes"
"context"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"syscall"
"testing" "testing"
"time"
"github.com/docker/cli/e2e/internal/fixtures"
"github.com/docker/cli/e2e/testutils"
"github.com/docker/cli/internal/test"
"github.com/docker/cli/internal/test/environment" "github.com/docker/cli/internal/test/environment"
"github.com/docker/docker/api/types/versions"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
"gotest.tools/v3/icmd" "gotest.tools/v3/icmd"
"gotest.tools/v3/poll"
"gotest.tools/v3/skip" "gotest.tools/v3/skip"
) )
@ -65,3 +76,185 @@ func TestTCPSchemeUsesHTTPProxyEnv(t *testing.T) {
assert.Equal(t, strings.TrimSpace(result.Stdout()), "99.99.9") assert.Equal(t, strings.TrimSpace(result.Stdout()), "99.99.9")
assert.Equal(t, received, "docker.acme.example.com:2376") assert.Equal(t, received, "docker.acme.example.com:2376")
} }
// Test that the prompt command exits with 0
// when the user sends SIGINT/SIGTERM to the process
func TestPromptExitCode(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
dir := fixtures.SetupConfigFile(t)
t.Cleanup(dir.Remove)
defaultCmdOpts := []icmd.CmdOp{
fixtures.WithConfig(dir.Path()),
fixtures.WithNotary,
}
testCases := []struct {
name string
run func(t *testing.T) icmd.Cmd
}{
{
name: "volume prune",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
return icmd.Command("docker", "volume", "prune")
},
},
{
name: "network prune",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
return icmd.Command("docker", "network", "prune")
},
},
{
name: "container prune",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
return icmd.Command("docker", "container", "prune")
},
},
{
name: "image prune",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
return icmd.Command("docker", "image", "prune")
},
},
{
name: "system prune",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
return icmd.Command("docker", "system", "prune")
},
},
{
name: "revoke trust",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
return icmd.Command("docker", "trust", "revoke", "example/trust-demo")
},
},
{
name: "plugin install",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
skip.If(t, versions.LessThan(environment.DaemonAPIVersion(t), "1.44"))
pluginDir := testutils.SetupPlugin(t, ctx)
t.Cleanup(pluginDir.Remove)
plugin := "registry:5000/plugin-content-trust-install:latest"
icmd.RunCommand("docker", "plugin", "create", plugin, pluginDir.Path()).Assert(t, icmd.Success)
icmd.RunCmd(icmd.Command("docker", "plugin", "push", plugin), defaultCmdOpts...).Assert(t, icmd.Success)
icmd.RunCmd(icmd.Command("docker", "plugin", "rm", "-f", plugin), defaultCmdOpts...).Assert(t, icmd.Success)
return icmd.Command("docker", "plugin", "install", plugin)
},
},
{
name: "plugin upgrade",
run: func(t *testing.T) icmd.Cmd {
t.Helper()
skip.If(t, versions.LessThan(environment.DaemonAPIVersion(t), "1.44"))
pluginLatestDir := testutils.SetupPlugin(t, ctx)
t.Cleanup(pluginLatestDir.Remove)
pluginNextDir := testutils.SetupPlugin(t, ctx)
t.Cleanup(pluginNextDir.Remove)
plugin := "registry:5000/plugin-content-trust-upgrade"
icmd.RunCommand("docker", "plugin", "create", plugin+":latest", pluginLatestDir.Path()).Assert(t, icmd.Success)
icmd.RunCommand("docker", "plugin", "create", plugin+":next", pluginNextDir.Path()).Assert(t, icmd.Success)
icmd.RunCmd(icmd.Command("docker", "plugin", "push", plugin+":latest"), defaultCmdOpts...).Assert(t, icmd.Success)
icmd.RunCmd(icmd.Command("docker", "plugin", "push", plugin+":next"), defaultCmdOpts...).Assert(t, icmd.Success)
icmd.RunCmd(icmd.Command("docker", "plugin", "rm", "-f", plugin+":latest"), defaultCmdOpts...).Assert(t, icmd.Success)
icmd.RunCmd(icmd.Command("docker", "plugin", "rm", "-f", plugin+":next"), defaultCmdOpts...).Assert(t, icmd.Success)
icmd.RunCmd(icmd.Command("docker", "plugin", "install", "--disable", "--grant-all-permissions", plugin+":latest"), defaultCmdOpts...).Assert(t, icmd.Success)
return icmd.Command("docker", "plugin", "upgrade", plugin+":latest", plugin+":next")
},
},
}
for _, tc := range testCases {
tc := tc
t.Run("case="+tc.name, func(t *testing.T) {
t.Parallel()
buf := new(bytes.Buffer)
bufioWriter := bufio.NewWriter(buf)
writeDone := make(chan struct{})
w := test.NewWriterWithHook(bufioWriter, func(p []byte) {
writeDone <- struct{}{}
})
drainChCtx, drainChCtxCancel := context.WithCancel(ctx)
t.Cleanup(drainChCtxCancel)
drainChannel(drainChCtx, writeDone)
r, _ := io.Pipe()
defer r.Close()
result := icmd.StartCmd(tc.run(t),
append(defaultCmdOpts,
icmd.WithStdout(w),
icmd.WithStderr(w),
icmd.WithStdin(r))...)
poll.WaitOn(t, func(t poll.LogT) poll.Result {
select {
case <-ctx.Done():
return poll.Error(ctx.Err())
default:
if err := bufioWriter.Flush(); err != nil {
return poll.Continue(err.Error())
}
if strings.Contains(buf.String(), "[y/N]") {
return poll.Success()
}
return poll.Continue("command did not prompt for confirmation, instead prompted:\n%s\n", buf.String())
}
}, poll.WithDelay(100*time.Millisecond), poll.WithTimeout(1*time.Second))
drainChCtxCancel()
assert.NilError(t, result.Cmd.Process.Signal(syscall.SIGINT))
proc, err := result.Cmd.Process.Wait()
assert.NilError(t, err)
assert.Equal(t, proc.ExitCode(), 0, "expected exit code to be 0, got %d", proc.ExitCode())
processCtx, processCtxCancel := context.WithTimeout(ctx, time.Second)
t.Cleanup(processCtxCancel)
select {
case <-processCtx.Done():
t.Fatal("timed out waiting for new line after process exit")
case <-writeDone:
buf.Reset()
assert.NilError(t, bufioWriter.Flush())
assert.Equal(t, buf.String(), "\n", "expected a new line after the process exits from SIGINT")
}
})
}
}
func drainChannel(ctx context.Context, ch <-chan struct{}) {
go func() {
for {
select {
case <-ctx.Done():
return
case <-ch:
}
}
}()
}

View File

@ -1,20 +1,14 @@
package plugin package plugin
import ( import (
"encoding/json" "context"
"fmt" "fmt"
"os"
"os/exec"
"path/filepath"
"testing" "testing"
"github.com/docker/cli/e2e/internal/fixtures" "github.com/docker/cli/e2e/internal/fixtures"
"github.com/docker/cli/e2e/testutils"
"github.com/docker/cli/internal/test/environment" "github.com/docker/cli/internal/test/environment"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/versions" "github.com/docker/docker/api/types/versions"
"github.com/pkg/errors"
"gotest.tools/v3/assert"
"gotest.tools/v3/fs"
"gotest.tools/v3/icmd" "gotest.tools/v3/icmd"
"gotest.tools/v3/skip" "gotest.tools/v3/skip"
) )
@ -31,8 +25,11 @@ func TestInstallWithContentTrust(t *testing.T) {
dir := fixtures.SetupConfigFile(t) dir := fixtures.SetupConfigFile(t)
defer dir.Remove() defer dir.Remove()
pluginDir := preparePluginDir(t) ctx, cancel := context.WithCancel(context.Background())
defer pluginDir.Remove() t.Cleanup(cancel)
pluginDir := testutils.SetupPlugin(t, ctx)
t.Cleanup(pluginDir.Remove)
icmd.RunCommand("docker", "plugin", "create", pluginName, pluginDir.Path()).Assert(t, icmd.Success) icmd.RunCommand("docker", "plugin", "create", pluginName, pluginDir.Path()).Assert(t, icmd.Success)
result := icmd.RunCmd(icmd.Command("docker", "plugin", "push", pluginName), result := icmd.RunCmd(icmd.Command("docker", "plugin", "push", pluginName),
@ -73,46 +70,3 @@ func TestInstallWithContentTrustUntrusted(t *testing.T) {
Err: "Error: remote trust data does not exist", Err: "Error: remote trust data does not exist",
}) })
} }
func preparePluginDir(t *testing.T) *fs.Dir {
t.Helper()
p := &types.PluginConfig{
Interface: types.PluginConfigInterface{
Socket: "basic.sock",
Types: []types.PluginInterfaceType{{Capability: "docker.dummy/1.0"}},
},
Entrypoint: []string{"/basic"},
}
configJSON, err := json.Marshal(p)
assert.NilError(t, err)
binPath, err := ensureBasicPluginBin()
assert.NilError(t, err)
dir := fs.NewDir(t, "plugin_test",
fs.WithFile("config.json", string(configJSON), fs.WithMode(0o644)),
fs.WithDir("rootfs", fs.WithMode(0o755)),
)
icmd.RunCommand("/bin/cp", binPath, dir.Join("rootfs", p.Entrypoint[0])).Assert(t, icmd.Success)
return dir
}
func ensureBasicPluginBin() (string, error) {
name := "docker-basic-plugin"
p, err := exec.LookPath(name)
if err == nil {
return p, nil
}
goBin, err := exec.LookPath("/usr/local/go/bin/go")
if err != nil {
return "", err
}
installPath := filepath.Join(os.Getenv("GOPATH"), "bin", name)
cmd := exec.Command(goBin, "build", "-o", installPath, "./basic")
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
return "", errors.Wrapf(err, "error building basic plugin bin: %s", string(out))
}
return installPath, nil
}

102
e2e/testutils/plugins.go Normal file
View File

@ -0,0 +1,102 @@
package testutils
import (
"context"
"crypto/rand"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/docker/docker/api/types"
"github.com/pkg/errors"
"gotest.tools/v3/assert"
"gotest.tools/v3/fs"
"gotest.tools/v3/icmd"
)
//go:embed plugins/*
var plugins embed.FS
// SetupPlugin builds a plugin and creates a temporary
// directory with the plugin's config.json and rootfs.
func SetupPlugin(t *testing.T, ctx context.Context) *fs.Dir {
t.Helper()
p := &types.PluginConfig{
Linux: types.PluginConfigLinux{
Capabilities: []string{"CAP_SYS_ADMIN"},
},
Interface: types.PluginConfigInterface{
Socket: "basic.sock",
Types: []types.PluginInterfaceType{{Capability: "docker.dummy/1.0"}},
},
Entrypoint: []string{"/basic"},
}
configJSON, err := json.Marshal(p)
assert.NilError(t, err)
binPath, err := buildPlugin(t, ctx)
assert.NilError(t, err)
dir := fs.NewDir(t, "plugin_test",
fs.WithFile("config.json", string(configJSON), fs.WithMode(0o644)),
fs.WithDir("rootfs", fs.WithMode(0o755)),
)
icmd.RunCommand("/bin/cp", binPath, dir.Join("rootfs", p.Entrypoint[0])).Assert(t, icmd.Success)
return dir
}
// buildPlugin uses Go to build a plugin from one of the source files in the plugins directory.
// It returns the path to the built plugin binary.
// To allow for multiple plugins to be built in parallel, the plugin is compiled with a unique
// identifier in the binary. This is done by setting a linker flag with the -ldflags option.
func buildPlugin(t *testing.T, ctx context.Context) (string, error) {
t.Helper()
randomName, err := randomString()
if err != nil {
return "", err
}
goBin, err := exec.LookPath("/usr/local/go/bin/go")
if err != nil {
return "", err
}
installPath := filepath.Join(os.Getenv("GOPATH"), "bin", randomName)
pluginContent, err := plugins.ReadFile("plugins/basic.go")
if err != nil {
return "", err
}
dir := fs.NewDir(t, "plugin_build")
if err := os.WriteFile(dir.Join("main.go"), pluginContent, 0o644); err != nil {
return "", err
}
defer dir.Remove()
cmd := exec.CommandContext(ctx, goBin, "build", "-ldflags",
fmt.Sprintf("-X 'main.UNIQUEME=%s'", randomName),
"-o", installPath, dir.Join("main.go"))
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
return "", errors.Wrapf(err, "error building basic plugin bin: %s", string(out))
}
return installPath, nil
}
func randomString() (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}

View File

@ -9,6 +9,11 @@ import (
"time" "time"
) )
// set by the compile flags to get around content sha being the same
var (
UNIQUEME string
)
func main() { func main() {
p, err := filepath.Abs(filepath.Join("run", "docker", "plugins")) p, err := filepath.Abs(filepath.Join("run", "docker", "plugins"))
if err != nil { if err != nil {

View File

@ -7,12 +7,13 @@ import (
"testing" "testing"
"time" "time"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
) )
func TerminatePrompt(ctx context.Context, t *testing.T, cmd *cobra.Command, cli *FakeCli, assertFunc func(*testing.T, error)) { func TerminatePrompt(ctx context.Context, t *testing.T, cmd *cobra.Command, cli *FakeCli) {
t.Helper() t.Helper()
errChan := make(chan error) errChan := make(chan error)
@ -73,11 +74,6 @@ func TerminatePrompt(ctx context.Context, t *testing.T, cmd *cobra.Command, cli
t.Logf("command stderr:\n%s\n", cli.ErrBuffer().String()) t.Logf("command stderr:\n%s\n", cli.ErrBuffer().String())
t.Fatalf("command %s did not return after SIGINT", cmd.Name()) t.Fatalf("command %s did not return after SIGINT", cmd.Name())
case err := <-errChan: case err := <-errChan:
if assertFunc != nil { assert.ErrorIs(t, err, command.ErrPromptTerminated)
assertFunc(t, err)
} else {
assert.NilError(t, err)
assert.Equal(t, cli.ErrBuffer().String(), "")
}
} }
} }

View File

@ -4,27 +4,22 @@ import (
"io" "io"
) )
// WriterWithHook is an io.Writer that calls a hook function type writerWithHook struct {
// after every write.
// This is useful in testing to wait for a write to complete,
// or to check what was written.
// To create a WriterWithHook use the NewWriterWithHook function.
type WriterWithHook struct {
actualWriter io.Writer actualWriter io.Writer
hook func([]byte) hook func([]byte)
} }
// Write writes p to the actual writer and then calls the hook function. func (w *writerWithHook) Write(p []byte) (n int, err error) {
func (w *WriterWithHook) Write(p []byte) (n int, err error) {
defer w.hook(p) defer w.hook(p)
return w.actualWriter.Write(p) return w.actualWriter.Write(p)
} }
var _ io.Writer = (*WriterWithHook)(nil) var _ io.Writer = (*writerWithHook)(nil)
// NewWriterWithHook returns a new WriterWithHook that still writes to the actualWriter // NewWriterWithHook returns a io.Writer that still
// but also calls the hook function after every write. // writes to the actualWriter but also calls the hook function
// The hook function is useful for testing, or waiting for a write to complete. // after every write. It is useful to use this function when
func NewWriterWithHook(actualWriter io.Writer, hook func([]byte)) *WriterWithHook { // you need to wait for a writer to complete writing inside a test.
return &WriterWithHook{actualWriter: actualWriter, hook: hook} func NewWriterWithHook(actualWriter io.Writer, hook func([]byte)) *writerWithHook {
return &writerWithHook{actualWriter: actualWriter, hook: hook}
} }