diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 6ebdabb3c5c..7ebaa0cac51 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -508,29 +508,42 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C ui.Mode = ui.ModeTTY } + var ep ui.EventProcessor switch opts.Progress { case "", ui.ModeAuto: - if ansi == "never" { + switch { + case ansi == "never": ui.Mode = ui.ModePlain + ep = ui.NewPlainWriter(dockerCli.Err()) + case dockerCli.Out().IsTerminal(): + ep = ui.NewTTYWriter(dockerCli.Err()) + default: + ep = ui.NewPlainWriter(dockerCli.Err()) } case ui.ModeTTY: if ansi == "never" { return fmt.Errorf("can't use --progress tty while ANSI support is disabled") } ui.Mode = ui.ModeTTY + ep = ui.NewTTYWriter(dockerCli.Err()) + case ui.ModePlain: if ansi == "always" { return fmt.Errorf("can't use --progress plain while ANSI support is forced") } ui.Mode = ui.ModePlain + ep = ui.NewPlainWriter(dockerCli.Err()) case ui.ModeQuiet, "none": ui.Mode = ui.ModeQuiet + ep = ui.NewQuietWriter() case ui.ModeJSON: ui.Mode = ui.ModeJSON logrus.SetFormatter(&logrus.JSONFormatter{}) + ep = ui.NewJSONWriter(dockerCli.Err()) default: return fmt.Errorf("unsupported --progress value %q", opts.Progress) } + backendOptions.Add(compose.WithEventProcessor(ep)) // (4) options validation / normalization if opts.WorkDir != "" { diff --git a/cmd/compose/run.go b/cmd/compose/run.go index b35a003cfb0..0894a8cbaed 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -25,6 +25,7 @@ import ( "github.com/compose-spec/compose-go/v2/dotenv" "github.com/compose-spec/compose-go/v2/format" "github.com/docker/compose/v2/pkg/compose" + "github.com/docker/compose/v2/pkg/progress" xprogress "github.com/moby/buildkit/util/progress/progressui" "github.com/sirupsen/logrus" @@ -38,7 +39,6 @@ import ( "github.com/docker/cli/cli" "github.com/docker/compose/v2/pkg/api" - "github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/utils" ) diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 08ea1837282..7b52792d4c3 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -27,6 +27,7 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli/command" "github.com/docker/compose/v2/pkg/compose" + ui "github.com/docker/compose/v2/pkg/progress" xprogress "github.com/moby/buildkit/util/progress/progressui" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -34,7 +35,6 @@ import ( "github.com/docker/compose/v2/cmd/formatter" "github.com/docker/compose/v2/pkg/api" - ui "github.com/docker/compose/v2/pkg/progress" "github.com/docker/compose/v2/pkg/utils" ) diff --git a/pkg/compose/build.go b/pkg/compose/build.go index 986761cd4fe..feddf56f9c3 100644 --- a/pkg/compose/build.go +++ b/pkg/compose/build.go @@ -58,13 +58,13 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti if err != nil { return err } - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { _, err := s.build(ctx, project, options, nil) return err })(ctx) - }, s.stdinfo(), "Building") + }, "build", s.events) } //nolint:gocyclo @@ -199,22 +199,17 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti return -1 } - cw := progress.ContextWriter(ctx) err = InDependencyOrder(ctx, project, func(ctx context.Context, name string) error { service, ok := serviceToBuild[name] if !ok { return nil } - serviceName := fmt.Sprintf("Service %s", name) - if !buildkitEnabled { trace.SpanFromContext(ctx).SetAttributes(attribute.String("builder", "classic")) - cw.Event(progress.BuildingEvent(serviceName)) id, err := s.doBuildClassic(ctx, project, service, options) if err != nil { return err } - cw.Event(progress.BuiltEvent(serviceName)) builtDigests[getServiceIndex(name)] = id if options.Push { @@ -231,6 +226,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti if err != nil { return err } + s.events.On(progress.BuildingEvent(buildOptions.Tags[0])) trace.SpanFromContext(ctx).SetAttributes(attribute.String("builder", "buildkit")) digest, err := s.doBuildBuildkit(ctx, name, buildOptions, w, nodes) @@ -260,7 +256,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti service := project.Services[names[i]] imageRef := api.GetImageNameOrDefault(service, project.Name) imageIDs[imageRef] = imageDigest - cw.Event(progress.BuiltEvent(names[i])) + s.events.On(progress.BuiltEvent(imageRef)) } } return imageIDs, err diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go index b161d3f2357..8407b8a1797 100644 --- a/pkg/compose/build_bake.go +++ b/pkg/compose/build_bake.go @@ -226,6 +226,8 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project } image := api.GetImageNameOrDefault(service, project.Name) + s.events.On(progress.BuildingEvent(image)) + expectedImages[serviceName] = image pull := service.Build.Pull || options.Pull @@ -340,7 +342,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project logrus.Debugf("Executing bake with args: %v", args) if s.dryRun { - return dryRunBake(ctx, cfg), nil + return s.dryRunBake(cfg), nil } cmd := exec.CommandContext(ctx, buildx.Path, args...) @@ -417,7 +419,6 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project return nil, err } - cw := progress.ContextWriter(ctx) results := map[string]string{} for name := range serviceToBeBuild { image := expectedImages[name] @@ -427,7 +428,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project return nil, fmt.Errorf("build result not found in Bake metadata for service %s", name) } results[image] = built.Digest - cw.Event(progress.BuiltEvent(image)) + s.events.On(progress.BuiltEvent(image)) } return results, nil } @@ -565,27 +566,26 @@ func dockerFilePath(ctxName string, dockerfile string) string { return dockerfile } -func dryRunBake(ctx context.Context, cfg bakeConfig) map[string]string { - w := progress.ContextWriter(ctx) +func (s composeService) dryRunBake(cfg bakeConfig) map[string]string { bakeResponse := map[string]string{} for name, target := range cfg.Targets { dryRunUUID := fmt.Sprintf("dryRun-%x", sha1.Sum([]byte(name))) - displayDryRunBuildEvent(w, name, dryRunUUID, target.Tags[0]) + s.displayDryRunBuildEvent(name, dryRunUUID, target.Tags[0]) bakeResponse[name] = dryRunUUID } for name := range bakeResponse { - w.Event(progress.BuiltEvent(name)) + s.events.On(progress.BuiltEvent(name)) } return bakeResponse } -func displayDryRunBuildEvent(w progress.Writer, name string, dryRunUUID, tag string) { - w.Event(progress.Event{ +func (s composeService) displayDryRunBuildEvent(name, dryRunUUID, tag string) { + s.events.On(progress.Event{ ID: name + " ==>", Status: progress.Done, Text: fmt.Sprintf("==> writing image %s", dryRunUUID), }) - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name + " ==> ==>", Status: progress.Done, Text: fmt.Sprintf(`naming to %s`, tag), diff --git a/pkg/compose/build_buildkit.go b/pkg/compose/build_buildkit.go index ccc5ae5defe..23046795761 100644 --- a/pkg/compose/build_buildkit.go +++ b/pkg/compose/build_buildkit.go @@ -30,7 +30,6 @@ import ( "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/dockerutil" buildx "github.com/docker/buildx/util/progress" - "github.com/docker/compose/v2/pkg/progress" "github.com/moby/buildkit/client" ) @@ -40,7 +39,7 @@ func (s *composeService) doBuildBuildkit(ctx context.Context, service string, op err error ) if s.dryRun { - response = s.dryRunBuildResponse(ctx, service, opts) + response = s.dryRunBuildResponse(service, opts) } else { response, err = build.Build(ctx, nodes, map[string]build.Options{service: opts}, @@ -66,11 +65,10 @@ func (s *composeService) doBuildBuildkit(ctx context.Context, service string, op return "", fmt.Errorf("buildkit response is missing expected result for %s", service) } -func (s composeService) dryRunBuildResponse(ctx context.Context, name string, options build.Options) map[string]*client.SolveResponse { - w := progress.ContextWriter(ctx) +func (s composeService) dryRunBuildResponse(name string, options build.Options) map[string]*client.SolveResponse { buildResponse := map[string]*client.SolveResponse{} dryRunUUID := fmt.Sprintf("dryRun-%x", sha1.Sum([]byte(name))) - displayDryRunBuildEvent(w, name, dryRunUUID, options.Tags[0]) + s.displayDryRunBuildEvent(name, dryRunUUID, options.Tags[0]) buildResponse[name] = &client.SolveResponse{ExporterResponse: map[string]string{ "containerimage.digest": dryRunUUID, }} diff --git a/pkg/compose/build_classic.go b/pkg/compose/build_classic.go index fb3fd296719..6a29d871929 100644 --- a/pkg/compose/build_classic.go +++ b/pkg/compose/build_classic.go @@ -30,6 +30,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command/image/build" "github.com/docker/compose/v2/pkg/api" + progress2 "github.com/docker/compose/v2/pkg/progress" buildtypes "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/registry" @@ -183,6 +184,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj ctx, cancel := context.WithCancel(ctx) defer cancel() + s.events.On(progress2.BuildingEvent(imageName)) response, err := s.apiClient().ImageBuild(ctx, body, buildOpts) if err != nil { return "", err @@ -211,6 +213,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj } return "", err } + s.events.On(progress2.BuiltEvent(imageName)) return imageID, nil } diff --git a/pkg/compose/commit.go b/pkg/compose/commit.go index 0edac43356e..520f795f9dd 100644 --- a/pkg/compose/commit.go +++ b/pkg/compose/commit.go @@ -27,9 +27,9 @@ import ( ) func (s *composeService) Commit(ctx context.Context, projectName string, options api.CommitOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.commit(ctx, projectName, options) - }, s.stdinfo(), "Committing") + }, "commit", s.events) } func (s *composeService) commit(ctx context.Context, projectName string, options api.CommitOptions) error { @@ -40,12 +40,10 @@ func (s *composeService) commit(ctx context.Context, projectName string, options return err } - w := progress.ContextWriter(ctx) - name := getCanonicalContainerName(ctr) msg := fmt.Sprintf("Commit %s", name) - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Working, @@ -53,7 +51,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options }) if s.dryRun { - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Done, @@ -74,7 +72,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options return err } - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Done, diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index 0e40a4785cf..d475d9a9426 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -32,6 +32,7 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/flags" "github.com/docker/cli/cli/streams" + "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/network" @@ -94,6 +95,9 @@ func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, e return defaultValue, nil } } + if s.events == nil { + s.events = progress.NewQuietWriter() + } // If custom streams were provided, wrap the Docker CLI to use them if s.outStream != nil || s.errStream != nil || s.inStream != nil { @@ -191,10 +195,21 @@ func WithDryRun(s *composeService) error { type Prompt func(message string, defaultValue bool) (bool, error) +// WithEventProcessor configure component to get notified on Compose operation and progress events. +// Typically used to configure a progress UI +func WithEventProcessor(bus progress.EventProcessor) Option { + return func(s *composeService) error { + s.events = bus + return nil + } +} + type composeService struct { dockerCli command.Cli // prompt is used to interact with user and confirm actions prompt Prompt + // eventBus collects tasks execution events + events progress.EventProcessor // Optional overrides for specific components (for SDK users) outStream io.Writer diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index e75fa94efe5..992b3987b18 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -57,7 +57,7 @@ const ( // Cross services dependencies are managed by creating services in expected order and updating `service:xx` reference // when a service has converged, so dependent ones can be managed with resolved containers references. type convergence struct { - service *composeService + compose *composeService services map[string]Containers networks map[string]string volumes map[string]string @@ -86,7 +86,7 @@ func newConvergence(services []string, state Containers, networks map[string]str observedState[service] = append(observedState[service], c) } return &convergence{ - service: s, + compose: s, services: observedState, networks: networks, volumes: volumes, @@ -112,7 +112,7 @@ func (c *convergence) apply(ctx context.Context, project *types.Project, options func (c *convergence) ensureService(ctx context.Context, project *types.Project, service types.ServiceConfig, recreate string, inherit bool, timeout *time.Duration) error { //nolint:gocyclo if service.Provider != nil { - return c.service.runPlugin(ctx, project, service, "up") + return c.compose.runPlugin(ctx, project, service, "up") } expected, err := getScale(service) if err != nil { @@ -159,7 +159,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, ctr := ctr traceOpts := append(tracing.ServiceOptions(service), tracing.ContainerOptions(ctr)...) eg.Go(tracing.SpanWrapFuncForErrGroup(ctx, "service/scale/down", traceOpts, func(ctx context.Context) error { - return c.service.stopAndRemoveContainer(ctx, ctr, &service, timeout, false) + return c.compose.stopAndRemoveContainer(ctx, ctr, &service, timeout, false) })) continue } @@ -176,7 +176,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, i, ctr := i, ctr eg.Go(tracing.SpanWrapFuncForErrGroup(ctx, "container/recreate", tracing.ContainerOptions(ctr), func(ctx context.Context) error { - recreated, err := c.service.recreateContainer(ctx, project, service, ctr, inherit, timeout) + recreated, err := c.compose.recreateContainer(ctx, project, service, ctr, inherit, timeout) updated[i] = recreated return err })) @@ -184,18 +184,17 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, } // Enforce non-diverged containers are running - w := progress.ContextWriter(ctx) name := getContainerProgressName(ctr) switch ctr.State { case container.StateRunning: - w.Event(progress.RunningEvent(name)) + c.compose.events.On(progress.RunningEvent(name)) case container.StateCreated: case container.StateRestarting: case container.StateExited: default: ctr := ctr eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "service/start", tracing.ContainerOptions(ctr), func(ctx context.Context) error { - return c.service.startContainer(ctx, ctr) + return c.compose.startContainer(ctx, ctr) })) } updated[i] = ctr @@ -214,7 +213,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project, UseNetworkAliases: true, Labels: mergeLabels(service.Labels, service.CustomLabels), } - ctr, err := c.service.createContainer(ctx, project, service, name, number, opts) + ctr, err := c.compose.createContainer(ctx, project, service, name, number, opts) updated[actual+i] = ctr return err })) @@ -234,7 +233,7 @@ func (c *convergence) stopDependentContainers(ctx context.Context, project *type if len(dependents) == 0 { return nil } - err := c.service.stop(ctx, project.Name, api.StopOptions{ + err := c.compose.stop(ctx, project.Name, api.StopOptions{ Services: dependents, Project: project, }, nil) @@ -454,7 +453,6 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr ctx = withTimeout } eg, _ := errgroup.WithContext(ctx) - w := progress.ContextWriter(ctx) for dep, config := range dependencies { if shouldWait, err := shouldWaitForDependency(dep, config, project); err != nil { return err @@ -463,7 +461,7 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr } waitingFor := containers.filter(isService(dep), isNotOneOff) - w.Events(containerEvents(waitingFor, progress.Waiting)) + s.events.On(containerEvents(waitingFor, progress.Waiting)...) if len(waitingFor) == 0 { if config.Required { return fmt.Errorf("%s is missing dependency %s", dependant, dep) @@ -486,29 +484,31 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr healthy, err := s.isServiceHealthy(ctx, waitingFor, true) if err != nil { if !config.Required { - w.Events(containerReasonEvents(waitingFor, progress.SkippedEvent, fmt.Sprintf("optional dependency %q is not running or is unhealthy", dep))) + s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent, + fmt.Sprintf("optional dependency %q is not running or is unhealthy", dep))...) logrus.Warnf("optional dependency %q is not running or is unhealthy: %s", dep, err.Error()) return nil } return err } if healthy { - w.Events(containerEvents(waitingFor, progress.Healthy)) + s.events.On(containerEvents(waitingFor, progress.Healthy)...) return nil } case types.ServiceConditionHealthy: healthy, err := s.isServiceHealthy(ctx, waitingFor, false) if err != nil { if !config.Required { - w.Events(containerReasonEvents(waitingFor, progress.SkippedEvent, fmt.Sprintf("optional dependency %q failed to start", dep))) + s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent, + fmt.Sprintf("optional dependency %q failed to start", dep))...) logrus.Warnf("optional dependency %q failed to start: %s", dep, err.Error()) return nil } - w.Events(containerEvents(waitingFor, progress.ErrorEvent)) + s.events.On(containerEvents(waitingFor, progress.ErrorEvent)...) return fmt.Errorf("dependency failed to start: %w", err) } if healthy { - w.Events(containerEvents(waitingFor, progress.Healthy)) + s.events.On(containerEvents(waitingFor, progress.Healthy)...) return nil } case types.ServiceConditionCompletedSuccessfully: @@ -518,20 +518,21 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr } if exited { if code == 0 { - w.Events(containerEvents(waitingFor, progress.Exited)) + s.events.On(containerEvents(waitingFor, progress.Exited)...) return nil } messageSuffix := fmt.Sprintf("%q didn't complete successfully: exit %d", dep, code) if !config.Required { // optional -> mark as skipped & don't propagate error - w.Events(containerReasonEvents(waitingFor, progress.SkippedEvent, fmt.Sprintf("optional dependency %s", messageSuffix))) + s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent, + fmt.Sprintf("optional dependency %s", messageSuffix))...) logrus.Warnf("optional dependency %s", messageSuffix) return nil } msg := fmt.Sprintf("service %s", messageSuffix) - w.Events(containerReasonEvents(waitingFor, progress.ErrorMessageEvent, msg)) + s.events.On(containerReasonEvents(waitingFor, progress.ErrorMessageEvent, msg)...) return errors.New(msg) } default: @@ -593,13 +594,12 @@ func nextContainerNumber(containers []container.Summary) int { func (s *composeService) createContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, opts createOptions, ) (ctr container.Summary, err error) { - w := progress.ContextWriter(ctx) eventName := "Container " + name - w.Event(progress.CreatingEvent(eventName)) - ctr, err = s.createMobyContainer(ctx, project, service, name, number, nil, opts, w) + s.events.On(progress.CreatingEvent(eventName)) + ctr, err = s.createMobyContainer(ctx, project, service, name, number, nil, opts) if err != nil { if ctx.Err() == nil { - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: eventName, Status: progress.Error, StatusText: err.Error(), @@ -607,19 +607,18 @@ func (s *composeService) createContainer(ctx context.Context, project *types.Pro } return } - w.Event(progress.CreatedEvent(eventName)) + s.events.On(progress.CreatedEvent(eventName)) return } func (s *composeService) recreateContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, replaced container.Summary, inherit bool, timeout *time.Duration, ) (created container.Summary, err error) { - w := progress.ContextWriter(ctx) eventName := getContainerProgressName(replaced) - w.Event(progress.NewEvent(eventName, progress.Working, "Recreate")) + s.events.On(progress.NewEvent(eventName, progress.Working, "Recreate")) defer func() { if err != nil && ctx.Err() == nil { - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: eventName, Status: progress.Error, StatusText: err.Error(), @@ -649,7 +648,7 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P UseNetworkAliases: true, Labels: mergeLabels(service.Labels, service.CustomLabels).Add(api.ContainerReplaceLabel, replacedContainerName), } - created, err = s.createMobyContainer(ctx, project, service, tmpName, number, inherited, opts, w) + created, err = s.createMobyContainer(ctx, project, service, tmpName, number, inherited, opts) if err != nil { return created, err } @@ -670,7 +669,7 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P return created, err } - w.Event(progress.NewEvent(eventName, progress.Done, "Recreated")) + s.events.On(progress.NewEvent(eventName, progress.Done, "Recreated")) return created, err } @@ -678,26 +677,19 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P var startMx sync.Mutex func (s *composeService) startContainer(ctx context.Context, ctr container.Summary) error { - w := progress.ContextWriter(ctx) - w.Event(progress.NewEvent(getContainerProgressName(ctr), progress.Working, "Restart")) + s.events.On(progress.NewEvent(getContainerProgressName(ctr), progress.Working, "Restart")) startMx.Lock() defer startMx.Unlock() err := s.apiClient().ContainerStart(ctx, ctr.ID, container.StartOptions{}) if err != nil { return err } - w.Event(progress.NewEvent(getContainerProgressName(ctr), progress.Done, "Restarted")) + s.events.On(progress.NewEvent(getContainerProgressName(ctr), progress.Done, "Restarted")) return nil } -func (s *composeService) createMobyContainer(ctx context.Context, - project *types.Project, - service types.ServiceConfig, - name string, - number int, - inherit *container.Summary, - opts createOptions, - w progress.Writer, +func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, + name string, number int, inherit *container.Summary, opts createOptions, ) (container.Summary, error) { var created container.Summary cfgs, err := s.getCreateConfigs(ctx, project, service, number, inherit, opts) @@ -723,7 +715,7 @@ func (s *composeService) createMobyContainer(ctx context.Context, return created, err } for _, warning := range response.Warnings { - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: service.Name, Status: progress.Warning, Text: warning, @@ -894,7 +886,6 @@ func (s *composeService) startService(ctx context.Context, return fmt.Errorf("service %q has no container to start", service.Name) } - w := progress.ContextWriter(ctx) for _, ctr := range containers.filter(isService(service.Name)) { if ctr.State == container.StateRunning { continue @@ -911,7 +902,7 @@ func (s *composeService) startService(ctx context.Context, } eventName := getContainerProgressName(ctr) - w.Event(progress.StartingEvent(eventName)) + s.events.On(progress.StartingEvent(eventName)) err = s.apiClient().ContainerStart(ctx, ctr.ID, container.StartOptions{}) if err != nil { return err @@ -924,7 +915,7 @@ func (s *composeService) startService(ctx context.Context, } } - w.Event(progress.StartedEvent(eventName)) + s.events.On(progress.StartedEvent(eventName)) } return nil } diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 319c811e765..e1d32dcb3cd 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -35,7 +35,6 @@ import ( "github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/mocks" - "github.com/docker/compose/v2/pkg/progress" ) func TestContainerName(t *testing.T) { @@ -87,9 +86,8 @@ func TestServiceLinks(t *testing.T) { apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() s.Links = []string{"db"} @@ -97,7 +95,7 @@ func TestServiceLinks(t *testing.T) { c := testContainer("db", dbContainerName, false) apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil) - links, err := tested.getLinks(context.Background(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -111,9 +109,8 @@ func TestServiceLinks(t *testing.T) { defer mockCtrl.Finish() apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() s.Links = []string{"db:db"} @@ -121,7 +118,7 @@ func TestServiceLinks(t *testing.T) { c := testContainer("db", dbContainerName, false) apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil) - links, err := tested.getLinks(context.Background(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -135,9 +132,8 @@ func TestServiceLinks(t *testing.T) { defer mockCtrl.Finish() apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() s.Links = []string{"db:dbname"} @@ -145,7 +141,7 @@ func TestServiceLinks(t *testing.T) { c := testContainer("db", dbContainerName, false) apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil) - links, err := tested.getLinks(context.Background(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -159,9 +155,8 @@ func TestServiceLinks(t *testing.T) { defer mockCtrl.Finish() apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() s.Links = []string{"db:dbname"} @@ -170,7 +165,7 @@ func TestServiceLinks(t *testing.T) { c := testContainer("db", dbContainerName, false) apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil) - links, err := tested.getLinks(context.Background(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1) assert.NilError(t, err) assert.Equal(t, len(links), 4) @@ -187,9 +182,8 @@ func TestServiceLinks(t *testing.T) { defer mockCtrl.Finish() apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() s.Links = []string{} @@ -208,7 +202,7 @@ func TestServiceLinks(t *testing.T) { } apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptionsOneOff).Return([]container.Summary{c}, nil) - links, err := tested.getLinks(context.Background(), testProject, s, 1) + links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1) assert.NilError(t, err) assert.Equal(t, len(links), 3) @@ -224,9 +218,8 @@ func TestWaitDependencies(t *testing.T) { apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() t.Run("should skip dependencies with scale 0", func(t *testing.T) { @@ -240,7 +233,7 @@ func TestWaitDependencies(t *testing.T) { "db": {Condition: ServiceConditionRunningOrHealthy}, "redis": {Condition: ServiceConditionRunningOrHealthy}, } - assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil, 0)) + assert.NilError(t, tested.(*composeService).waitDependencies(context.Background(), &project, "", dependencies, nil, 0)) }) t.Run("should skip dependencies with condition service_started", func(t *testing.T) { dbService := types.ServiceConfig{Name: "db", Scale: intPtr(1)} @@ -253,7 +246,7 @@ func TestWaitDependencies(t *testing.T) { "db": {Condition: types.ServiceConditionStarted, Required: true}, "redis": {Condition: types.ServiceConditionStarted, Required: true}, } - assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil, 0)) + assert.NilError(t, tested.(*composeService).waitDependencies(context.Background(), &project, "", dependencies, nil, 0)) }) } @@ -263,9 +256,8 @@ func TestCreateMobyContainer(t *testing.T) { defer mockCtrl.Finish() apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() apiClient.EXPECT().DaemonHost().Return("").AnyTimes() @@ -341,9 +333,9 @@ func TestCreateMobyContainer(t *testing.T) { Aliases: []string{"bork-test-0"}, })) - _, err := tested.createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{ + _, err = tested.(*composeService).createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{ Labels: make(types.Labels), - }, progress.ContextWriter(context.TODO())) + }) assert.NilError(t, err) }) @@ -352,9 +344,8 @@ func TestCreateMobyContainer(t *testing.T) { defer mockCtrl.Finish() apiClient := mocks.NewMockAPIClient(mockCtrl) cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() apiClient.EXPECT().DaemonHost().Return("").AnyTimes() @@ -428,9 +419,9 @@ func TestCreateMobyContainer(t *testing.T) { NetworkSettings: &container.NetworkSettings{}, }, nil) - _, err := tested.createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{ + _, err = tested.(*composeService).createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{ Labels: make(types.Labels), - }, progress.ContextWriter(context.TODO())) + }) assert.NilError(t, err) }) } diff --git a/pkg/compose/cp.go b/pkg/compose/cp.go index b7db3296989..65c9185b07c 100644 --- a/pkg/compose/cp.go +++ b/pkg/compose/cp.go @@ -43,9 +43,9 @@ const ( ) func (s *composeService) Copy(ctx context.Context, projectName string, options api.CopyOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.copy(ctx, projectName, options) - }, s.stdinfo(), "Copying") + }, "copy", s.events) } func (s *composeService) copy(ctx context.Context, projectName string, options api.CopyOptions) error { @@ -79,7 +79,6 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a return err } - w := progress.ContextWriter(ctx) g := errgroup.Group{} for _, cont := range containers { ctr := cont @@ -91,7 +90,7 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a } else { msg = fmt.Sprintf("copy %s to %s:%s", srcPath, name, dstPath) } - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Working, @@ -100,7 +99,7 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a if err := copyFunc(ctx, ctr.ID, srcPath, dstPath, options); err != nil { return err } - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Done, diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 277350906aa..07f1085ad62 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -61,9 +61,9 @@ type createConfigs struct { } func (s *composeService) Create(ctx context.Context, project *types.Project, createOpts api.CreateOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.create(ctx, project, createOpts) - }, s.stdinfo(), "Creating") + }, "create", s.events) } func (s *composeService) create(ctx context.Context, project *types.Project, options api.CreateOptions) error { @@ -1394,15 +1394,14 @@ func (s *composeService) resolveOrCreateNetwork(ctx context.Context, project *ty } networkEventName := fmt.Sprintf("Network %s", n.Name) - w := progress.ContextWriter(ctx) - w.Event(progress.CreatingEvent(networkEventName)) + s.events.On(progress.CreatingEvent(networkEventName)) resp, err := s.apiClient().NetworkCreate(ctx, n.Name, createOpts) if err != nil { - w.Event(progress.ErrorEvent(networkEventName)) + s.events.On(progress.ErrorEvent(networkEventName)) return "", fmt.Errorf("failed to create network %s: %w", n.Name, err) } - w.Event(progress.CreatedEvent(networkEventName)) + s.events.On(progress.CreatedEvent(networkEventName)) err = s.connectNetwork(ctx, n.Name, dangledContainers, nil) if err != nil { @@ -1444,7 +1443,7 @@ func (s *composeService) removeDivergedNetwork(ctx context.Context, project *typ err = s.apiClient().NetworkRemove(ctx, n.Name) eventName := fmt.Sprintf("Network %s", n.Name) - progress.ContextWriter(ctx).Event(progress.RemovedEvent(eventName)) + s.events.On(progress.RemovedEvent(eventName)) return containers, err } @@ -1623,8 +1622,7 @@ func (s *composeService) removeDivergedVolume(ctx context.Context, name string, func (s *composeService) createVolume(ctx context.Context, volume types.VolumeConfig) error { eventName := fmt.Sprintf("Volume %s", volume.Name) - w := progress.ContextWriter(ctx) - w.Event(progress.CreatingEvent(eventName)) + s.events.On(progress.CreatingEvent(eventName)) hash, err := VolumeHash(volume) if err != nil { return err @@ -1637,9 +1635,9 @@ func (s *composeService) createVolume(ctx context.Context, volume types.VolumeCo DriverOpts: volume.DriverOpts, }) if err != nil { - w.Event(progress.ErrorEvent(eventName)) + s.events.On(progress.ErrorEvent(eventName)) return err } - w.Event(progress.CreatedEvent(eventName)) + s.events.On(progress.CreatedEvent(eventName)) return nil } diff --git a/pkg/compose/down.go b/pkg/compose/down.go index e9619fbe652..159c42a24c1 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -40,11 +40,10 @@ type downOp func() error func (s *composeService) Down(ctx context.Context, projectName string, options api.DownOptions) error { return progress.Run(ctx, func(ctx context.Context) error { return s.down(ctx, strings.ToLower(projectName), options) - }, s.stdinfo()) + }, "down", s.events) } func (s *composeService) down(ctx context.Context, projectName string, options api.DownOptions) error { //nolint:gocyclo - w := progress.ContextWriter(ctx) resourceToRemove := false include := oneOffExclude @@ -102,10 +101,10 @@ func (s *composeService) down(ctx context.Context, projectName string, options a } } - ops := s.ensureNetworksDown(ctx, project, w) + ops := s.ensureNetworksDown(ctx, project) if options.Images != "" { - imgOps, err := s.ensureImagesDown(ctx, project, options, w) + imgOps, err := s.ensureImagesDown(ctx, project, options) if err != nil { return err } @@ -113,7 +112,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a } if options.Volumes { - ops = append(ops, s.ensureVolumesDown(ctx, project, w)...) + ops = append(ops, s.ensureVolumesDown(ctx, project)...) } if !resourceToRemove && len(ops) == 0 { @@ -144,7 +143,7 @@ func checkSelectedServices(options api.DownOptions, project *types.Project) ([]s return services, nil } -func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.Project, w progress.Writer) []downOp { +func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.Project) []downOp { var ops []downOp for _, vol := range project.Volumes { if vol.External { @@ -152,14 +151,14 @@ func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.P } volumeName := vol.Name ops = append(ops, func() error { - return s.removeVolume(ctx, volumeName, w) + return s.removeVolume(ctx, volumeName) }) } return ops } -func (s *composeService) ensureImagesDown(ctx context.Context, project *types.Project, options api.DownOptions, w progress.Writer) ([]downOp, error) { +func (s *composeService) ensureImagesDown(ctx context.Context, project *types.Project, options api.DownOptions) ([]downOp, error) { imagePruner := NewImagePruner(s.apiClient(), project) pruneOpts := ImagePruneOptions{ Mode: ImagePruneMode(options.Images), @@ -174,13 +173,13 @@ func (s *composeService) ensureImagesDown(ctx context.Context, project *types.Pr for i := range images { img := images[i] ops = append(ops, func() error { - return s.removeImage(ctx, img, w) + return s.removeImage(ctx, img) }) } return ops, nil } -func (s *composeService) ensureNetworksDown(ctx context.Context, project *types.Project, w progress.Writer) []downOp { +func (s *composeService) ensureNetworksDown(ctx context.Context, project *types.Project) []downOp { var ops []downOp for key, n := range project.Networks { if n.External { @@ -190,13 +189,13 @@ func (s *composeService) ensureNetworksDown(ctx context.Context, project *types. networkKey := key idOrName := n.Name ops = append(ops, func() error { - return s.removeNetwork(ctx, networkKey, project.Name, idOrName, w) + return s.removeNetwork(ctx, networkKey, project.Name, idOrName) }) } return ops } -func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName string, projectName string, name string, w progress.Writer) error { +func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName string, projectName string, name string) error { networks, err := s.apiClient().NetworkList(ctx, network.ListOptions{ Filters: filters.NewArgs( projectFilter(projectName), @@ -211,7 +210,7 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s } eventName := fmt.Sprintf("Network %s", name) - w.Event(progress.RemovingEvent(eventName)) + s.events.On(progress.RemovingEvent(eventName)) var found int for _, net := range networks { @@ -220,14 +219,14 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s } nw, err := s.apiClient().NetworkInspect(ctx, net.ID, network.InspectOptions{}) if errdefs.IsNotFound(err) { - w.Event(progress.NewEvent(eventName, progress.Warning, "No resource found to remove")) + s.events.On(progress.NewEvent(eventName, progress.Warning, "No resource found to remove")) return nil } if err != nil { return err } if len(nw.Containers) > 0 { - w.Event(progress.NewEvent(eventName, progress.Warning, "Resource is still in use")) + s.events.On(progress.NewEvent(eventName, progress.Warning, "Resource is still in use")) found++ continue } @@ -236,10 +235,10 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s if errdefs.IsNotFound(err) { continue } - w.Event(progress.ErrorEvent(eventName)) + s.events.On(progress.ErrorEvent(eventName)) return fmt.Errorf("failed to remove network %s: %w", name, err) } - w.Event(progress.RemovedEvent(eventName)) + s.events.On(progress.RemovedEvent(eventName)) found++ } @@ -247,32 +246,32 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s // in practice, it's extremely unlikely for this to ever occur, as it'd // mean the network was present when we queried at the start of this // method but was then deleted by something else in the interim - w.Event(progress.NewEvent(eventName, progress.Warning, "No resource found to remove")) + s.events.On(progress.NewEvent(eventName, progress.Warning, "No resource found to remove")) return nil } return nil } -func (s *composeService) removeImage(ctx context.Context, image string, w progress.Writer) error { +func (s *composeService) removeImage(ctx context.Context, image string) error { id := fmt.Sprintf("Image %s", image) - w.Event(progress.NewEvent(id, progress.Working, "Removing")) + s.events.On(progress.NewEvent(id, progress.Working, "Removing")) _, err := s.apiClient().ImageRemove(ctx, image, imageapi.RemoveOptions{}) if err == nil { - w.Event(progress.NewEvent(id, progress.Done, "Removed")) + s.events.On(progress.NewEvent(id, progress.Done, "Removed")) return nil } if errdefs.IsConflict(err) { - w.Event(progress.NewEvent(id, progress.Warning, "Resource is still in use")) + s.events.On(progress.NewEvent(id, progress.Warning, "Resource is still in use")) return nil } if errdefs.IsNotFound(err) { - w.Event(progress.NewEvent(id, progress.Done, "Warning: No resource found to remove")) + s.events.On(progress.NewEvent(id, progress.Done, "Warning: No resource found to remove")) return nil } return err } -func (s *composeService) removeVolume(ctx context.Context, id string, w progress.Writer) error { +func (s *composeService) removeVolume(ctx context.Context, id string) error { resource := fmt.Sprintf("Volume %s", id) _, err := s.apiClient().VolumeInspect(ctx, id) @@ -281,30 +280,26 @@ func (s *composeService) removeVolume(ctx context.Context, id string, w progress return nil } - w.Event(progress.NewEvent(resource, progress.Working, "Removing")) + s.events.On(progress.NewEvent(resource, progress.Working, "Removing")) err = s.apiClient().VolumeRemove(ctx, id, true) if err == nil { - w.Event(progress.NewEvent(resource, progress.Done, "Removed")) + s.events.On(progress.NewEvent(resource, progress.Done, "Removed")) return nil } if errdefs.IsConflict(err) { - w.Event(progress.NewEvent(resource, progress.Warning, "Resource is still in use")) + s.events.On(progress.NewEvent(resource, progress.Warning, "Resource is still in use")) return nil } if errdefs.IsNotFound(err) { - w.Event(progress.NewEvent(resource, progress.Done, "Warning: No resource found to remove")) + s.events.On(progress.NewEvent(resource, progress.Done, "Warning: No resource found to remove")) return nil } return err } -func (s *composeService) stopContainer( - ctx context.Context, w progress.Writer, - service *types.ServiceConfig, ctr containerType.Summary, - timeout *time.Duration, listener api.ContainerEventListener, -) error { +func (s *composeService) stopContainer(ctx context.Context, service *types.ServiceConfig, ctr containerType.Summary, timeout *time.Duration, listener api.ContainerEventListener) error { eventName := getContainerProgressName(ctr) - w.Event(progress.StoppingEvent(eventName)) + s.events.On(progress.StoppingEvent(eventName)) if service != nil { for _, hook := range service.PreStop { @@ -322,22 +317,18 @@ func (s *composeService) stopContainer( timeoutInSecond := utils.DurationSecondToInt(timeout) err := s.apiClient().ContainerStop(ctx, ctr.ID, containerType.StopOptions{Timeout: timeoutInSecond}) if err != nil { - w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping")) + s.events.On(progress.ErrorMessageEvent(eventName, "Error while Stopping")) return err } - w.Event(progress.StoppedEvent(eventName)) + s.events.On(progress.StoppedEvent(eventName)) return nil } -func (s *composeService) stopContainers( - ctx context.Context, w progress.Writer, - serv *types.ServiceConfig, containers []containerType.Summary, - timeout *time.Duration, listener api.ContainerEventListener, -) error { +func (s *composeService) stopContainers(ctx context.Context, serv *types.ServiceConfig, containers []containerType.Summary, timeout *time.Duration, listener api.ContainerEventListener) error { eg, ctx := errgroup.WithContext(ctx) for _, ctr := range containers { eg.Go(func() error { - return s.stopContainer(ctx, w, serv, ctr, timeout, listener) + return s.stopContainer(ctx, serv, ctr, timeout, listener) }) } return eg.Wait() @@ -354,26 +345,25 @@ func (s *composeService) removeContainers(ctx context.Context, containers []cont } func (s *composeService) stopAndRemoveContainer(ctx context.Context, ctr containerType.Summary, service *types.ServiceConfig, timeout *time.Duration, volumes bool) error { - w := progress.ContextWriter(ctx) eventName := getContainerProgressName(ctr) - err := s.stopContainer(ctx, w, service, ctr, timeout, nil) + err := s.stopContainer(ctx, service, ctr, timeout, nil) if errdefs.IsNotFound(err) { - w.Event(progress.RemovedEvent(eventName)) + s.events.On(progress.RemovedEvent(eventName)) return nil } if err != nil { return err } - w.Event(progress.RemovingEvent(eventName)) + s.events.On(progress.RemovingEvent(eventName)) err = s.apiClient().ContainerRemove(ctx, ctr.ID, containerType.RemoveOptions{ Force: true, RemoveVolumes: volumes, }) if err != nil && !errdefs.IsNotFound(err) && !errdefs.IsConflict(err) { - w.Event(progress.ErrorMessageEvent(eventName, "Error while Removing")) + s.events.On(progress.ErrorMessageEvent(eventName, "Error while Removing")) return err } - w.Event(progress.RemovedEvent(eventName)) + s.events.On(progress.RemovedEvent(eventName)) return nil } diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index 58c1ec0b14c..e9aba1d3b02 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -43,9 +43,8 @@ func TestDown(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( []container.Summary{ @@ -91,7 +90,7 @@ func TestDown(t *testing.T) { api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil) api.EXPECT().NetworkRemove(gomock.Any(), "def456").Return(nil) - err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{}) + err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{}) assert.NilError(t, err) } @@ -100,9 +99,8 @@ func TestDownWithGivenServices(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( []container.Summary{ @@ -141,7 +139,7 @@ func TestDownWithGivenServices(t *testing.T) { api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(network.Inspect{ID: "abc123"}, nil) api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil) - err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{ + err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{ Services: []string{"service1", "not-running-service"}, }) assert.NilError(t, err) @@ -152,9 +150,8 @@ func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( []container.Summary{ @@ -178,7 +175,7 @@ func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) { {ID: "def456", Name: "myProject_default", Labels: map[string]string{compose.NetworkLabel: "default"}}, }, nil) - err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{ + err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{ Services: []string{"not-running-service1", "not-running-service2"}, }) assert.NilError(t, err) @@ -189,9 +186,8 @@ func TestDownRemoveOrphans(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(true)).Return( []container.Summary{ @@ -231,7 +227,7 @@ func TestDownRemoveOrphans(t *testing.T) { api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(network.Inspect{ID: "abc123"}, nil) api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil) - err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{RemoveOrphans: true}) + err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{RemoveOrphans: true}) assert.NilError(t, err) } @@ -240,9 +236,8 @@ func TestDownRemoveVolumes(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( []container.Summary{testContainer("service1", "123", false)}, nil) @@ -264,7 +259,7 @@ func TestDownRemoveVolumes(t *testing.T) { api.EXPECT().VolumeRemove(gomock.Any(), "myProject_volume", true).Return(nil) - err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Volumes: true}) + err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Volumes: true}) assert.NilError(t, err) } @@ -287,9 +282,8 @@ func TestDownRemoveImages(t *testing.T) { } api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)). Return([]container.Summary{ @@ -352,7 +346,7 @@ func TestDownRemoveImages(t *testing.T) { t.Log("-> docker compose down --rmi=local") opts.Images = "local" - err := tested.Down(context.Background(), strings.ToLower(testProject), opts) + err = tested.Down(context.Background(), strings.ToLower(testProject), opts) assert.NilError(t, err) otherImagesToBeRemoved := []string{ @@ -376,9 +370,8 @@ func TestDownRemoveImages_NoLabel(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) ctr := testContainer("service1", "123", false) @@ -413,7 +406,7 @@ func TestDownRemoveImages_NoLabel(t *testing.T) { api.EXPECT().ImageRemove(gomock.Any(), "testproject-service1:latest", image.RemoveOptions{}).Return(nil, nil) - err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Images: "local"}) + err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Images: "local"}) assert.NilError(t, err) } diff --git a/pkg/compose/export.go b/pkg/compose/export.go index 21dc8ad2058..85755352b36 100644 --- a/pkg/compose/export.go +++ b/pkg/compose/export.go @@ -29,9 +29,9 @@ import ( ) func (s *composeService) Export(ctx context.Context, projectName string, options api.ExportOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.export(ctx, projectName, options) - }, s.stdinfo(), "Exporting") + }, "export", s.events) } func (s *composeService) export(ctx context.Context, projectName string, options api.ExportOptions) error { @@ -50,12 +50,10 @@ func (s *composeService) export(ctx context.Context, projectName string, options return fmt.Errorf("failed to export container: %w", err) } - w := progress.ContextWriter(ctx) - name := getCanonicalContainerName(container) msg := fmt.Sprintf("export %s to %s", name, options.Output) - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Working, @@ -69,7 +67,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options defer func() { if err := responseBody.Close(); err != nil { - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Error, @@ -94,7 +92,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options } } - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Text: msg, Status: progress.Done, diff --git a/pkg/compose/images_test.go b/pkg/compose/images_test.go index 85a2b577007..80006a481b2 100644 --- a/pkg/compose/images_test.go +++ b/pkg/compose/images_test.go @@ -37,9 +37,8 @@ func TestImages(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) ctx := context.Background() args := filters.NewArgs(projectFilter(strings.ToLower(testProject))) diff --git a/pkg/compose/kill.go b/pkg/compose/kill.go index badf90e03e4..1b54cb61fbb 100644 --- a/pkg/compose/kill.go +++ b/pkg/compose/kill.go @@ -29,14 +29,12 @@ import ( ) func (s *composeService) Kill(ctx context.Context, projectName string, options api.KillOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.kill(ctx, strings.ToLower(projectName), options) - }, s.stdinfo(), "Killing") + }, "kill", s.events) } func (s *composeService) kill(ctx context.Context, projectName string, options api.KillOptions) error { - w := progress.ContextWriter(ctx) - services := options.Services var containers Containers @@ -65,13 +63,13 @@ func (s *composeService) kill(ctx context.Context, projectName string, options a containers.forEach(func(ctr container.Summary) { eg.Go(func() error { eventName := getContainerProgressName(ctr) - w.Event(progress.KillingEvent(eventName)) + s.events.On(progress.KillingEvent(eventName)) err := s.apiClient().ContainerKill(ctx, ctr.ID, options.Signal) if err != nil { - w.Event(progress.ErrorMessageEvent(eventName, "Error while Killing")) + s.events.On(progress.ErrorMessageEvent(eventName, "Error while Killing")) return err } - w.Event(progress.KilledEvent(eventName)) + s.events.On(progress.KilledEvent(eventName)) return nil }) }) diff --git a/pkg/compose/kill_test.go b/pkg/compose/kill_test.go index 5877f5ea659..b45bb924c93 100644 --- a/pkg/compose/kill_test.go +++ b/pkg/compose/kill_test.go @@ -40,9 +40,8 @@ func TestKillAll(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) name := strings.ToLower(testProject) @@ -65,7 +64,7 @@ func TestKillAll(t *testing.T) { api.EXPECT().ContainerKill(anyCancellableContext(), "456", "").Return(nil) api.EXPECT().ContainerKill(anyCancellableContext(), "789", "").Return(nil) - err := tested.kill(ctx, name, compose.KillOptions{}) + err = tested.Kill(ctx, name, compose.KillOptions{}) assert.NilError(t, err) } @@ -75,9 +74,8 @@ func TestKillSignal(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) name := strings.ToLower(testProject) listOptions := container.ListOptions{ @@ -98,7 +96,7 @@ func TestKillSignal(t *testing.T) { }, nil) api.EXPECT().ContainerKill(anyCancellableContext(), "123", "SIGTERM").Return(nil) - err := tested.kill(ctx, name, compose.KillOptions{Services: []string{serviceName}, Signal: "SIGTERM"}) + err = tested.Kill(ctx, name, compose.KillOptions{Services: []string{serviceName}, Signal: "SIGTERM"}) assert.NilError(t, err) } diff --git a/pkg/compose/logs_test.go b/pkg/compose/logs_test.go index 955b5e770d5..8c6802dce37 100644 --- a/pkg/compose/logs_test.go +++ b/pkg/compose/logs_test.go @@ -39,9 +39,8 @@ func TestComposeService_Logs_Demux(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + require.NoError(t, err) name := strings.ToLower(testProject) @@ -88,7 +87,7 @@ func TestComposeService_Logs_Demux(t *testing.T) { } consumer := &testLogConsumer{} - err := tested.Logs(ctx, name, consumer, opts) + err = tested.Logs(ctx, name, consumer, opts) require.NoError(t, err) require.Equal( @@ -110,9 +109,8 @@ func TestComposeService_Logs_ServiceFiltering(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + require.NoError(t, err) name := strings.ToLower(testProject) @@ -159,7 +157,7 @@ func TestComposeService_Logs_ServiceFiltering(t *testing.T) { opts := compose.LogOptions{ Project: proj, } - err := tested.Logs(ctx, name, consumer, opts) + err = tested.Logs(ctx, name, consumer, opts) require.NoError(t, err) require.Equal(t, []string{"hello c1"}, consumer.LogsForContainer("c1")) diff --git a/pkg/compose/model.go b/pkg/compose/model.go index 386e73e559a..7ae67321ca4 100644 --- a/pkg/compose/model.go +++ b/pkg/compose/model.go @@ -51,19 +51,18 @@ func (s *composeService) ensureModels(ctx context.Context, project *types.Projec return api.SetModelVariables(ctx, project) }) - w := progress.ContextWriter(ctx) for name, config := range project.Models { if config.Name == "" { config.Name = name } eg.Go(func() error { if !slices.Contains(availableModels, config.Model) { - err = api.PullModel(ctx, config, quietPull, w) + err = api.PullModel(ctx, config, quietPull, s.events) if err != nil { return err } } - return api.ConfigureModel(ctx, config, w) + return api.ConfigureModel(ctx, config, s.events) }) } return eg.Wait() @@ -102,8 +101,8 @@ func (m *modelAPI) Close() { m.cleanup() } -func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quietPull bool, w progress.Writer) error { - w.Event(progress.Event{ +func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quietPull bool, events progress.EventProcessor) error { + events.On(progress.Event{ ID: model.Name, Status: progress.Working, Text: "Pulling", @@ -132,7 +131,7 @@ func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quiet } if !quietPull { - w.Event(progress.Event{ + events.On(progress.Event{ ID: model.Name, Status: progress.Working, Text: "Pulling", @@ -143,21 +142,21 @@ func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quiet err = cmd.Wait() if err != nil { - w.Event(progress.ErrorMessageEvent(model.Name, err.Error())) + events.On(progress.ErrorMessageEvent(model.Name, err.Error())) } - w.Event(progress.Event{ - ID: model.Name, - Status: progress.Working, - Text: "Pulled", + events.On(progress.Event{ + ID: model.Name, + Status: progress.Working, + StatusText: "Pulled", }) return err } -func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, w progress.Writer) error { - w.Event(progress.Event{ - ID: config.Name, - Status: progress.Working, - Text: "Configuring", +func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, events progress.EventProcessor) error { + events.On(progress.Event{ + ID: config.Name, + Status: progress.Working, + StatusText: "Configuring", }) // configure [--context-size=] MODEL [-- ] args := []string{"configure"} diff --git a/pkg/compose/pause.go b/pkg/compose/pause.go index 4f86d0e0920..cfa5d2c7710 100644 --- a/pkg/compose/pause.go +++ b/pkg/compose/pause.go @@ -28,9 +28,9 @@ import ( ) func (s *composeService) Pause(ctx context.Context, projectName string, options api.PauseOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.pause(ctx, strings.ToLower(projectName), options) - }, s.stdinfo(), "Pausing") + }, "pause", s.events) } func (s *composeService) pause(ctx context.Context, projectName string, options api.PauseOptions) error { @@ -43,14 +43,13 @@ func (s *composeService) pause(ctx context.Context, projectName string, options containers = containers.filter(isService(options.Project.ServiceNames()...)) } - w := progress.ContextWriter(ctx) eg, ctx := errgroup.WithContext(ctx) containers.forEach(func(container container.Summary) { eg.Go(func() error { err := s.apiClient().ContainerPause(ctx, container.ID) if err == nil { eventName := getContainerProgressName(container) - w.Event(progress.NewEvent(eventName, progress.Done, "Paused")) + s.events.On(progress.NewEvent(eventName, progress.Done, "Paused")) } return err }) @@ -61,7 +60,7 @@ func (s *composeService) pause(ctx context.Context, projectName string, options func (s *composeService) UnPause(ctx context.Context, projectName string, options api.PauseOptions) error { return progress.Run(ctx, func(ctx context.Context) error { return s.unPause(ctx, strings.ToLower(projectName), options) - }, s.stdinfo()) + }, "unpause", s.events) } func (s *composeService) unPause(ctx context.Context, projectName string, options api.PauseOptions) error { @@ -74,14 +73,13 @@ func (s *composeService) unPause(ctx context.Context, projectName string, option containers = containers.filter(isService(options.Project.ServiceNames()...)) } - w := progress.ContextWriter(ctx) eg, ctx := errgroup.WithContext(ctx) containers.forEach(func(ctr container.Summary) { eg.Go(func() error { err = s.apiClient().ContainerUnpause(ctx, ctr.ID) if err == nil { eventName := getContainerProgressName(ctr) - w.Event(progress.NewEvent(eventName, progress.Done, "Unpaused")) + s.events.On(progress.NewEvent(eventName, progress.Done, "Unpaused")) } return err }) diff --git a/pkg/compose/plugins.go b/pkg/compose/plugins.go index 25bebfa9e82..f0f1d9b030e 100644 --- a/pkg/compose/plugins.go +++ b/pkg/compose/plugins.go @@ -66,7 +66,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project, return err } - variables, err := s.executePlugin(ctx, cmd, command, service) + variables, err := s.executePlugin(cmd, command, service) if err != nil { return err } @@ -85,15 +85,14 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project, return nil } -func (s *composeService) executePlugin(ctx context.Context, cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) { - pw := progress.ContextWriter(ctx) +func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) { var action string switch command { case "up": - pw.Event(progress.CreatingEvent(service.Name)) + s.events.On(progress.CreatingEvent(service.Name)) action = "create" case "down": - pw.Event(progress.RemovingEvent(service.Name)) + s.events.On(progress.RemovingEvent(service.Name)) action = "remove" default: return nil, fmt.Errorf("unsupported plugin command: %s", command) @@ -125,10 +124,10 @@ func (s *composeService) executePlugin(ctx context.Context, cmd *exec.Cmd, comma } switch msg.Type { case ErrorType: - pw.Event(progress.NewEvent(service.Name, progress.Error, msg.Message)) + s.events.On(progress.NewEvent(service.Name, progress.Error, msg.Message)) return nil, errors.New(msg.Message) case InfoType: - pw.Event(progress.NewEvent(service.Name, progress.Working, msg.Message)) + s.events.On(progress.NewEvent(service.Name, progress.Working, msg.Message)) case SetEnvType: key, val, found := strings.Cut(msg.Message, "=") if !found { @@ -144,14 +143,14 @@ func (s *composeService) executePlugin(ctx context.Context, cmd *exec.Cmd, comma err = cmd.Wait() if err != nil { - pw.Event(progress.ErrorMessageEvent(service.Name, err.Error())) + s.events.On(progress.ErrorMessageEvent(service.Name, err.Error())) return nil, fmt.Errorf("failed to %s service provider: %s", action, err.Error()) } switch command { case "up": - pw.Event(progress.CreatedEvent(service.Name)) + s.events.On(progress.CreatedEvent(service.Name)) case "down": - pw.Event(progress.RemovedEvent(service.Name)) + s.events.On(progress.RemovedEvent(service.Name)) } return variables, nil } diff --git a/pkg/compose/ps_test.go b/pkg/compose/ps_test.go index ac2230e4ddb..9a62d3461d9 100644 --- a/pkg/compose/ps_test.go +++ b/pkg/compose/ps_test.go @@ -34,9 +34,8 @@ func TestPs(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) ctx := context.Background() args := filters.NewArgs(projectFilter(strings.ToLower(testProject)), hasConfigHashLabel()) diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index ad57d830d43..15abce6f1e5 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -43,9 +43,9 @@ import ( ) func (s *composeService) Publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.publish(ctx, project, repository, options) - }, s.stdinfo(), "Publishing") + }, "publish", s.events) } //nolint:gocyclo @@ -71,8 +71,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re return err } - w := progress.ContextWriter(ctx) - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: repository, Text: "publishing", Status: progress.Working, @@ -94,7 +93,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion) if err != nil { - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: repository, Text: "publishing", Status: progress.Error, @@ -146,7 +145,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re } } } - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: repository, Text: "published", Status: progress.Done, diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index a5cb2a2fb4e..dddfb3faadc 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -35,6 +35,7 @@ import ( "github.com/docker/docker/client" "github.com/docker/docker/pkg/jsonmessage" "github.com/opencontainers/go-digest" + "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "github.com/docker/compose/v2/internal/registry" @@ -43,9 +44,9 @@ import ( ) func (s *composeService) Pull(ctx context.Context, project *types.Project, options api.PullOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.pull(ctx, project, options) - }, s.stdinfo(), "Pulling") + }, "pull", s.events) } func (s *composeService) pull(ctx context.Context, project *types.Project, opts api.PullOptions) error { //nolint:gocyclo @@ -54,7 +55,6 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts return err } - w := progress.ContextWriter(ctx) eg, ctx := errgroup.WithContext(ctx) eg.SetLimit(s.maxConcurrency) @@ -67,7 +67,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts i := 0 for name, service := range project.Services { if service.Image == "" { - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: name, Status: progress.Done, Text: "Skipped - No image to be pulled", @@ -77,16 +77,16 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts switch service.PullPolicy { case types.PullPolicyNever, types.PullPolicyBuild: - w.Event(progress.Event{ - ID: name, + s.events.On(progress.Event{ + ID: "Image " + service.Image, Status: progress.Done, Text: "Skipped", }) continue case types.PullPolicyMissing, types.PullPolicyIfNotPresent: if imageAlreadyPresent(service.Image, images) { - w.Event(progress.Event{ - ID: name, + s.events.On(progress.Event{ + ID: "Image " + service.Image, Status: progress.Done, Text: "Skipped - Image is already present locally", }) @@ -95,20 +95,15 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts } if service.Build != nil && opts.IgnoreBuildable { - w.Event(progress.Event{ - ID: name, + s.events.On(progress.Event{ + ID: "Image " + service.Image, Status: progress.Done, Text: "Skipped - Image can be built", }) continue } - if s, ok := imagesBeingPulled[service.Image]; ok { - w.Event(progress.Event{ - ID: name, - Status: progress.Done, - Text: fmt.Sprintf("Skipped - Image is already being pulled by %v", s), - }) + if _, ok := imagesBeingPulled[service.Image]; ok { continue } @@ -116,7 +111,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts idx := i eg.Go(func() error { - _, err := s.pullServiceImage(ctx, service, w, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"]) + _, err := s.pullServiceImage(ctx, service, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"]) if err != nil { pullErrors[idx] = err if service.Build != nil { @@ -124,8 +119,8 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts } if !opts.IgnoreFailures && service.Build == nil { if s.dryRun { - w.Event(progress.Event{ - ID: name, + s.events.On(progress.Event{ + ID: "Image " + service.Image, Status: progress.Error, Text: fmt.Sprintf(" - Pull error for image: %s", service.Image), }) @@ -142,7 +137,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts err = eg.Wait() if len(mustBuild) > 0 { - w.TailMsgf("WARNING: Some service image(s) must be built from source by running:\n docker compose build %s", strings.Join(mustBuild, " ")) + logrus.Warnf("WARNING: Some service image(s) must be built from source by running:\n docker compose build %s", strings.Join(mustBuild, " ")) } if err != nil { @@ -177,12 +172,9 @@ func getUnwrappedErrorMessage(err error) string { return err.Error() } -func (s *composeService) pullServiceImage(ctx context.Context, service types.ServiceConfig, w progress.Writer, quietPull bool, defaultPlatform string) (string, error) { - w.Event(progress.Event{ - ID: service.Name, - Status: progress.Working, - Text: "Pulling", - }) +func (s *composeService) pullServiceImage(ctx context.Context, service types.ServiceConfig, quietPull bool, defaultPlatform string) (string, error) { + resource := "Image " + service.Image + s.events.On(progress.PullingEvent(service.Image)) ref, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return "", err @@ -204,8 +196,8 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser }) if ctx.Err() != nil { - w.Event(progress.Event{ - ID: service.Name, + s.events.On(progress.Event{ + ID: resource, Status: progress.Warning, StatusText: "Interrupted", }) @@ -215,8 +207,8 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser // check if has error and the service has a build section // then the status should be warning instead of error if err != nil && service.Build != nil { - w.Event(progress.Event{ - ID: service.Name, + s.events.On(progress.Event{ + ID: resource, Status: progress.Warning, Text: "Warning", StatusText: getUnwrappedErrorMessage(err), @@ -225,8 +217,8 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser } if err != nil { - w.Event(progress.Event{ - ID: service.Name, + s.events.On(progress.Event{ + ID: resource, Status: progress.Error, Text: "Error", StatusText: getUnwrappedErrorMessage(err), @@ -247,14 +239,10 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser return "", errors.New(jm.Error.Message) } if !quietPull { - toPullProgressEvent(service.Name, jm, w) + toPullProgressEvent(resource, jm, s.events) } } - w.Event(progress.Event{ - ID: service.Name, - Status: progress.Done, - Text: "Pulled", - }) + s.events.On(progress.PulledEvent(service.Image)) inspected, err := s.apiClient().ImageInspect(ctx, service.Image) if err != nil { @@ -320,37 +308,34 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types. return nil } - return progress.Run(ctx, func(ctx context.Context) error { - w := progress.ContextWriter(ctx) - eg, ctx := errgroup.WithContext(ctx) - eg.SetLimit(s.maxConcurrency) - pulledImages := map[string]api.ImageSummary{} - var mutex sync.Mutex - for name, service := range needPull { - eg.Go(func() error { - id, err := s.pullServiceImage(ctx, service, w, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"]) - mutex.Lock() - defer mutex.Unlock() - pulledImages[name] = api.ImageSummary{ - ID: id, - Repository: service.Image, - LastTagTime: time.Now(), - } - if err != nil && isServiceImageToBuild(service, project.Services) { - // image can be built, so we can ignore pull failure - return nil - } - return err - }) - } - err := eg.Wait() - for i, service := range needPull { - if pulledImages[i].ID != "" { - images[service.Image] = pulledImages[i] + eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(s.maxConcurrency) + pulledImages := map[string]api.ImageSummary{} + var mutex sync.Mutex + for name, service := range needPull { + eg.Go(func() error { + id, err := s.pullServiceImage(ctx, service, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"]) + mutex.Lock() + defer mutex.Unlock() + pulledImages[name] = api.ImageSummary{ + ID: id, + Repository: service.Image, + LastTagTime: time.Now(), + } + if err != nil && isServiceImageToBuild(service, project.Services) { + // image can be built, so we can ignore pull failure + return nil } + return err + }) + } + err := eg.Wait() + for i, service := range needPull { + if pulledImages[i].ID != "" { + images[service.Image] = pulledImages[i] } - return err - }, s.stdinfo()) + } + return err } func mustPull(service types.ServiceConfig, images map[string]api.ImageSummary) (bool, error) { @@ -414,7 +399,7 @@ const ( PullCompletePhase = "Pull complete" ) -func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.Writer) { +func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events progress.EventProcessor) { if jm.ID == "" || jm.Progress == nil { return } @@ -456,7 +441,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.W text = jm.Error.Message } - w.Event(progress.Event{ + events.On(progress.Event{ ID: jm.ID, ParentID: parent, Current: current, diff --git a/pkg/compose/push.go b/pkg/compose/push.go index 8ae66bc50ec..54d4ef643a8 100644 --- a/pkg/compose/push.go +++ b/pkg/compose/push.go @@ -40,22 +40,21 @@ func (s *composeService) Push(ctx context.Context, project *types.Project, optio if options.Quiet { return s.push(ctx, project, options) } - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.push(ctx, project, options) - }, s.stdinfo(), "Pushing") + }, "push", s.events) } func (s *composeService) push(ctx context.Context, project *types.Project, options api.PushOptions) error { eg, ctx := errgroup.WithContext(ctx) eg.SetLimit(s.maxConcurrency) - w := progress.ContextWriter(ctx) for _, service := range project.Services { if service.Build == nil || service.Image == "" { if options.ImageMandatory && service.Image == "" && service.Provider == nil { return fmt.Errorf("%q attribute is mandatory to push an image for service %q", "service.image", service.Name) } - w.Event(progress.Event{ + s.events.On(progress.Event{ ID: service.Name, Status: progress.Done, Text: "Skipped", @@ -69,12 +68,16 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio for _, tag := range tags { eg.Go(func() error { - err := s.pushServiceImage(ctx, tag, w, options.Quiet) + s.events.On(progress.NewEvent(tag, progress.Working, "Pushing")) + err := s.pushServiceImage(ctx, tag, options.Quiet) if err != nil { if !options.IgnoreFailures { + s.events.On(progress.NewEvent(tag, progress.Error, err.Error())) return err } - w.TailMsgf("Pushing %s: %s", service.Name, err.Error()) + s.events.On(progress.NewEvent(tag, progress.Warning, err.Error())) + } else { + s.events.On(progress.NewEvent(tag, progress.Done, "Pushed")) } return nil }) @@ -83,7 +86,7 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio return eg.Wait() } -func (s *composeService) pushServiceImage(ctx context.Context, tag string, w progress.Writer, quietPush bool) error { +func (s *composeService) pushServiceImage(ctx context.Context, tag string, quietPush bool) error { ref, err := reference.ParseNormalizedNamed(tag) if err != nil { return err @@ -119,14 +122,14 @@ func (s *composeService) pushServiceImage(ctx context.Context, tag string, w pro } if !quietPush { - toPushProgressEvent(tag, jm, w) + toPushProgressEvent(tag, jm, s.events) } } return nil } -func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.Writer) { +func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, events progress.EventProcessor) { if jm.ID == "" { // skipped return @@ -157,8 +160,9 @@ func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.W } } - w.Event(progress.Event{ - ID: fmt.Sprintf("Pushing %s: %s", prefix, jm.ID), + events.On(progress.Event{ + ParentID: prefix, + ID: jm.ID, Text: jm.Status, Status: status, Current: current, diff --git a/pkg/compose/remove.go b/pkg/compose/remove.go index 64ce5c4cab0..a41645c1180 100644 --- a/pkg/compose/remove.go +++ b/pkg/compose/remove.go @@ -92,24 +92,23 @@ func (s *composeService) Remove(ctx context.Context, projectName string, options return nil } } - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.remove(ctx, stoppedContainers, options) - }, s.stdinfo(), "Removing") + }, "remove", s.events) } func (s *composeService) remove(ctx context.Context, containers Containers, options api.RemoveOptions) error { - w := progress.ContextWriter(ctx) eg, ctx := errgroup.WithContext(ctx) for _, ctr := range containers { eg.Go(func() error { eventName := getContainerProgressName(ctr) - w.Event(progress.RemovingEvent(eventName)) + s.events.On(progress.RemovingEvent(eventName)) err := s.apiClient().ContainerRemove(ctx, ctr.ID, container.RemoveOptions{ RemoveVolumes: options.Volumes, Force: options.Force, }) if err == nil { - w.Event(progress.RemovedEvent(eventName)) + s.events.On(progress.RemovedEvent(eventName)) } return err }) diff --git a/pkg/compose/restart.go b/pkg/compose/restart.go index c8714be775a..43f54d85897 100644 --- a/pkg/compose/restart.go +++ b/pkg/compose/restart.go @@ -29,9 +29,9 @@ import ( ) func (s *composeService) Restart(ctx context.Context, projectName string, options api.RestartOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.restart(ctx, strings.ToLower(projectName), options) - }, s.stdinfo(), "Restarting") + }, "restart", s.events) } func (s *composeService) restart(ctx context.Context, projectName string, options api.RestartOptions) error { //nolint:gocyclo @@ -75,7 +75,6 @@ func (s *composeService) restart(ctx context.Context, projectName string, option } } - w := progress.ContextWriter(ctx) return InDependencyOrder(ctx, project, func(c context.Context, service string) error { config := project.Services[service] err = s.waitDependencies(ctx, project, service, config.DependsOn, containers, 0) @@ -94,13 +93,13 @@ func (s *composeService) restart(ctx context.Context, projectName string, option } } eventName := getContainerProgressName(ctr) - w.Event(progress.RestartingEvent(eventName)) + s.events.On(progress.RestartingEvent(eventName)) timeout := utils.DurationSecondToInt(options.Timeout) err = s.apiClient().ContainerRestart(ctx, ctr.ID, container.StopOptions{Timeout: timeout}) if err != nil { return err } - w.Event(progress.StartedEvent(eventName)) + s.events.On(progress.StartedEvent(eventName)) for _, hook := range def.PostStart { err = s.runHook(ctx, ctr, def, hook, nil) if err != nil { diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 0e454e6a2f3..5f337bb3c4f 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -67,7 +67,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, err = progress.Run(ctx, func(ctx context.Context) error { return s.startDependencies(ctx, project, opts) - }, s.stdinfo()) + }, "run", s.events) if err != nil { return "", err } diff --git a/pkg/compose/scale.go b/pkg/compose/scale.go index 0f3cd69512d..124c63e957e 100644 --- a/pkg/compose/scale.go +++ b/pkg/compose/scale.go @@ -31,5 +31,5 @@ func (s *composeService) Scale(ctx context.Context, project *types.Project, opti return err } return s.start(ctx, project.Name, api.StartOptions{Project: project, Services: options.Services}, nil) - }), s.stdinfo()) + }), "scale", s.events) } diff --git a/pkg/compose/start.go b/pkg/compose/start.go index b0bde1f688b..ad691191d0b 100644 --- a/pkg/compose/start.go +++ b/pkg/compose/start.go @@ -33,7 +33,7 @@ import ( func (s *composeService) Start(ctx context.Context, projectName string, options api.StartOptions) error { return progress.Run(ctx, func(ctx context.Context) error { return s.start(ctx, strings.ToLower(projectName), options, nil) - }, s.stdinfo()) + }, "start", s.events) } func (s *composeService) start(ctx context.Context, projectName string, options api.StartOptions, listener api.ContainerEventListener) error { diff --git a/pkg/compose/stop.go b/pkg/compose/stop.go index 8a9cf5aa373..de21ad69039 100644 --- a/pkg/compose/stop.go +++ b/pkg/compose/stop.go @@ -26,9 +26,9 @@ import ( ) func (s *composeService) Stop(ctx context.Context, projectName string, options api.StopOptions) error { - return progress.RunWithTitle(ctx, func(ctx context.Context) error { + return progress.Run(ctx, func(ctx context.Context) error { return s.stop(ctx, strings.ToLower(projectName), options, nil) - }, s.stdinfo(), "Stopping") + }, "stop", s.events) } func (s *composeService) stop(ctx context.Context, projectName string, options api.StopOptions, event api.ContainerEventListener) error { @@ -49,12 +49,11 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a options.Services = project.ServiceNames() } - w := progress.ContextWriter(ctx) return InReverseDependencyOrder(ctx, project, func(c context.Context, service string) error { if !slices.Contains(options.Services, service) { return nil } serv := project.Services[service] - return s.stopContainers(ctx, w, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event) + return s.stopContainers(ctx, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event) }) } diff --git a/pkg/compose/stop_test.go b/pkg/compose/stop_test.go index 9c5d79ef7e7..edc00e34450 100644 --- a/pkg/compose/stop_test.go +++ b/pkg/compose/stop_test.go @@ -38,9 +38,8 @@ func TestStopTimeout(t *testing.T) { defer mockCtrl.Finish() api, cli := prepareMocks(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + assert.NilError(t, err) ctx := context.Background() api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( @@ -64,7 +63,7 @@ func TestStopTimeout(t *testing.T) { api.EXPECT().ContainerStop(gomock.Any(), "456", stopConfig).Return(nil) api.EXPECT().ContainerStop(gomock.Any(), "789", stopConfig).Return(nil) - err := tested.Stop(ctx, strings.ToLower(testProject), compose.StopOptions{ + err = tested.Stop(ctx, strings.ToLower(testProject), compose.StopOptions{ Timeout: &timeout, }) assert.NilError(t, err) diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 84d518523c9..40d866d8a7d 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -49,7 +49,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options return s.start(ctx, project.Name, options.Start, nil) } return nil - }), s.stdinfo()) + }), "up", s.events) if err != nil { return err } @@ -128,12 +128,10 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options first = false fmt.Println("Gracefully Stopping... press Ctrl+C again to force") eg.Go(func() error { - err := progress.RunWithLog(context.WithoutCancel(globalCtx), func(c context.Context) error { - return s.stop(c, project.Name, api.StopOptions{ - Services: options.Create.Services, - Project: project, - }, printer.HandleEvent) - }, s.stdinfo(), logConsumer) + err = s.stop(context.WithoutCancel(globalCtx), project.Name, api.StopOptions{ + Services: options.Create.Services, + Project: project, + }, printer.HandleEvent) appendErr(err) return nil }) @@ -209,12 +207,10 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options exitCode = event.ExitCode _, _ = fmt.Fprintln(s.stdinfo(), progress.ErrorColor("Aborting on container exit...")) eg.Go(func() error { - err := progress.RunWithLog(context.WithoutCancel(globalCtx), func(c context.Context) error { - return s.stop(c, project.Name, api.StopOptions{ - Services: options.Create.Services, - Project: project, - }, printer.HandleEvent) - }, s.stdinfo(), logConsumer) + err = s.stop(context.WithoutCancel(globalCtx), project.Name, api.StopOptions{ + Services: options.Create.Services, + Project: project, + }, printer.HandleEvent) appendErr(err) return nil }) diff --git a/pkg/compose/viz_test.go b/pkg/compose/viz_test.go index ba420d1230f..a2a1d0e7ebb 100644 --- a/pkg/compose/viz_test.go +++ b/pkg/compose/viz_test.go @@ -116,9 +116,8 @@ func TestViz(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() cli := mocks.NewMockCli(mockCtrl) - tested := composeService{ - dockerCli: cli, - } + tested, err := NewComposeService(cli) + require.NoError(t, err) ctx := context.Background() diff --git a/pkg/e2e/compose_run_test.go b/pkg/e2e/compose_run_test.go index 36a1fd49c2f..1bd53c51333 100644 --- a/pkg/e2e/compose_run_test.go +++ b/pkg/e2e/compose_run_test.go @@ -187,8 +187,8 @@ func TestLocalComposeRun(t *testing.T) { res.Assert(t, icmd.Success) res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/pull.yaml", "run", "--pull", "always", "backend") - assert.Assert(t, strings.Contains(res.Combined(), "backend Pulling"), res.Combined()) - assert.Assert(t, strings.Contains(res.Combined(), "backend Pulled"), res.Combined()) + assert.Assert(t, strings.Contains(res.Combined(), "Image nginx Pulling"), res.Combined()) + assert.Assert(t, strings.Contains(res.Combined(), "Image nginx Pulled"), res.Combined()) }) t.Run("compose run --env-from-file", func(t *testing.T) { diff --git a/pkg/e2e/pull_test.go b/pkg/e2e/pull_test.go index 473b490bd9c..de3541bbd8e 100644 --- a/pkg/e2e/pull_test.go +++ b/pkg/e2e/pull_test.go @@ -34,31 +34,15 @@ func TestComposePull(t *testing.T) { res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/simple", "pull") output := res.Combined() - assert.Assert(t, strings.Contains(output, "simple Pulled")) - assert.Assert(t, strings.Contains(output, "another Pulled")) + assert.Assert(t, strings.Contains(output, "Image alpine:3.14 Pulled")) + assert.Assert(t, strings.Contains(output, "Image alpine:3.15 Pulled")) // verify default policy is 'always' for pull command res = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/simple", "pull") output = res.Combined() - assert.Assert(t, strings.Contains(output, "simple Pulled")) - assert.Assert(t, strings.Contains(output, "another Pulled")) - }) - - t.Run("Verify a image is pulled once", func(t *testing.T) { - // cleanup existing images - c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/duplicate-images", "down", "--rmi", "all") - - res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/duplicate-images", "pull") - output := res.Combined() - - if strings.Contains(output, "another Pulled") { - assert.Assert(t, strings.Contains(output, "another Pulled")) - assert.Assert(t, strings.Contains(output, "Skipped - Image is already being pulled by another")) - } else { - assert.Assert(t, strings.Contains(output, "simple Pulled")) - assert.Assert(t, strings.Contains(output, "Skipped - Image is already being pulled by simple")) - } + assert.Assert(t, strings.Contains(output, "Image alpine:3.14 Pulled")) + assert.Assert(t, strings.Contains(output, "Image alpine:3.15 Pulled")) }) t.Run("Verify skipped pull if image is already present locally", func(t *testing.T) { @@ -68,9 +52,9 @@ func TestComposePull(t *testing.T) { res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/image-present-locally", "pull") output := res.Combined() - assert.Assert(t, strings.Contains(output, "simple Skipped - Image is already present locally")) + assert.Assert(t, strings.Contains(output, "alpine:3.13.12 Skipped - Image is already present locally")) // image with :latest tag gets pulled regardless if pull_policy: missing or if_not_present - assert.Assert(t, strings.Contains(output, "latest Pulled")) + assert.Assert(t, strings.Contains(output, "alpine:latest Pulled")) }) t.Run("Verify skipped no image to be pulled", func(t *testing.T) { diff --git a/pkg/progress/event.go b/pkg/progress/event.go index b2fdcb6ebfb..4a9f6c95a18 100644 --- a/pkg/progress/event.go +++ b/pkg/progress/event.go @@ -16,26 +16,11 @@ package progress -import ( - "time" -) +import "context" // EventStatus indicates the status of an action type EventStatus int -func (s EventStatus) colorFn() colorFunc { - switch s { - case Done: - return SuccessColor - case Warning: - return WarningColor - case Error: - return ErrorColor - default: - return nocolor - } -} - const ( // Working means that the current task is working Working EventStatus = iota @@ -47,6 +32,30 @@ const ( Error ) +const ( + StatusError = "Error" + StatusCreating = "Creating" + StatusStarting = "Starting" + StatusStarted = "Started" + StatusWaiting = "Waiting" + StatusHealthy = "Healthy" + StatusExited = "Exited" + StatusRestarting = "Restarting" + StatusRestarted = "Restarted" + StatusRunning = "Running" + StatusCreated = "Created" + StatusStopping = "Stopping" + StatusStopped = "Stopped" + StatusKilling = "Killing" + StatusKilled = "Killed" + StatusRemoving = "Removing" + StatusRemoved = "Removed" + StatusBuilding = "Building" + StatusBuilt = "Built" + StatusPulling = "Pulling" + StatusPulled = "Pulled" +) + // Event represents a progress event. type Event struct { ID string @@ -56,11 +65,7 @@ type Event struct { StatusText string Current int64 Percent int - - Total int64 - startTime time.Time - endTime time.Time - spinner *Spinner + Total int64 } // ErrorMessageEvent creates a new Error Event with message @@ -70,97 +75,107 @@ func ErrorMessageEvent(id string, msg string) Event { // ErrorEvent creates a new Error Event func ErrorEvent(id string) Event { - return NewEvent(id, Error, "Error") + return NewEvent(id, Error, StatusError) } // CreatingEvent creates a new Create in progress Event func CreatingEvent(id string) Event { - return NewEvent(id, Working, "Creating") + return NewEvent(id, Working, StatusCreating) } // StartingEvent creates a new Starting in progress Event func StartingEvent(id string) Event { - return NewEvent(id, Working, "Starting") + return NewEvent(id, Working, StatusStarting) } // StartedEvent creates a new Started in progress Event func StartedEvent(id string) Event { - return NewEvent(id, Done, "Started") + return NewEvent(id, Done, StatusStarted) } // Waiting creates a new waiting event func Waiting(id string) Event { - return NewEvent(id, Working, "Waiting") + return NewEvent(id, Working, StatusWaiting) } // Healthy creates a new healthy event func Healthy(id string) Event { - return NewEvent(id, Done, "Healthy") + return NewEvent(id, Done, StatusHealthy) } // Exited creates a new exited event func Exited(id string) Event { - return NewEvent(id, Done, "Exited") + return NewEvent(id, Done, StatusExited) } // RestartingEvent creates a new Restarting in progress Event func RestartingEvent(id string) Event { - return NewEvent(id, Working, "Restarting") + return NewEvent(id, Working, StatusRestarting) } // RestartedEvent creates a new Restarted in progress Event func RestartedEvent(id string) Event { - return NewEvent(id, Done, "Restarted") + return NewEvent(id, Done, StatusRestarted) } // RunningEvent creates a new Running in progress Event func RunningEvent(id string) Event { - return NewEvent(id, Done, "Running") + return NewEvent(id, Done, StatusRunning) } // CreatedEvent creates a new Created (done) Event func CreatedEvent(id string) Event { - return NewEvent(id, Done, "Created") + return NewEvent(id, Done, StatusCreated) } // StoppingEvent creates a new Stopping in progress Event func StoppingEvent(id string) Event { - return NewEvent(id, Working, "Stopping") + return NewEvent(id, Working, StatusStopping) } // StoppedEvent creates a new Stopping in progress Event func StoppedEvent(id string) Event { - return NewEvent(id, Done, "Stopped") + return NewEvent(id, Done, StatusStopped) } // KillingEvent creates a new Killing in progress Event func KillingEvent(id string) Event { - return NewEvent(id, Working, "Killing") + return NewEvent(id, Working, StatusKilling) } // KilledEvent creates a new Killed in progress Event func KilledEvent(id string) Event { - return NewEvent(id, Done, "Killed") + return NewEvent(id, Done, StatusKilled) } // RemovingEvent creates a new Removing in progress Event func RemovingEvent(id string) Event { - return NewEvent(id, Working, "Removing") + return NewEvent(id, Working, StatusRemoving) } // RemovedEvent creates a new removed (done) Event func RemovedEvent(id string) Event { - return NewEvent(id, Done, "Removed") + return NewEvent(id, Done, StatusRemoved) } // BuildingEvent creates a new Building in progress Event func BuildingEvent(id string) Event { - return NewEvent(id, Working, "Building") + return NewEvent("Image "+id, Working, StatusBuilding) } // BuiltEvent creates a new built (done) Event func BuiltEvent(id string) Event { - return NewEvent(id, Done, "Built") + return NewEvent("Image "+id, Done, StatusBuilt) +} + +// PullingEvent creates a new pulling (in progress) Event +func PullingEvent(id string) Event { + return NewEvent("Image "+id, Working, StatusPulling) +} + +// PulledEvent creates a new pulled (done) Event +func PulledEvent(id string) Event { + return NewEvent("Image "+id, Done, StatusPulled) } // SkippedEvent creates a new Skipped Event @@ -181,30 +196,12 @@ func NewEvent(id string, status EventStatus, statusText string) Event { } } -func (e *Event) stop() { - e.endTime = time.Now() - e.spinner.Stop() -} - -func (e *Event) hasMore() { - e.spinner.Restart() -} - -var ( - spinnerDone = "✔" - spinnerWarning = "!" - spinnerError = "✘" -) - -func (e *Event) Spinner() any { - switch e.Status { - case Done: - return SuccessColor(spinnerDone) - case Warning: - return WarningColor(spinnerWarning) - case Error: - return ErrorColor(spinnerError) - default: - return CountColor(e.spinner.String()) - } +// EventProcessor is notified about Compose operations and tasks +type EventProcessor interface { + // Start is triggered as a Compose operation is starting with context + Start(ctx context.Context, operation string) + // On notify about (sub)task and progress processing operation + On(events ...Event) + // Done is triggered as a Compose operation completed + Done(operation string, success bool) } diff --git a/pkg/progress/json.go b/pkg/progress/json.go index 219eac9b2ec..81601eab06a 100644 --- a/pkg/progress/json.go +++ b/pkg/progress/json.go @@ -23,9 +23,14 @@ import ( "io" ) +func NewJSONWriter(out io.Writer) EventProcessor { + return &jsonWriter{ + out: out, + } +} + type jsonWriter struct { out io.Writer - done chan bool dryRun bool } @@ -41,13 +46,7 @@ type jsonMessage struct { Percent int `json:"percent,omitempty"` } -func (p *jsonWriter) Start(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case <-p.done: - return nil - } +func (p *jsonWriter) Start(ctx context.Context, operation string) { } func (p *jsonWriter) Event(e Event) { @@ -68,29 +67,11 @@ func (p *jsonWriter) Event(e Event) { } } -func (p *jsonWriter) Events(events []Event) { +func (p *jsonWriter) On(events ...Event) { for _, e := range events { p.Event(e) } } -func (p *jsonWriter) TailMsgf(msg string, args ...interface{}) { - message := &jsonMessage{ - DryRun: p.dryRun, - Tail: true, - ID: "", - Text: fmt.Sprintf(msg, args...), - Status: "", - } - marshal, err := json.Marshal(message) - if err == nil { - _, _ = fmt.Fprintln(p.out, string(marshal)) - } -} - -func (p *jsonWriter) Stop() { - p.done <- true -} - -func (p *jsonWriter) HasMore(bool) { +func (p *jsonWriter) Done(_ string, _ bool) { } diff --git a/pkg/progress/json_test.go b/pkg/progress/json_test.go index fffe535af08..d351963e553 100644 --- a/pkg/progress/json_test.go +++ b/pkg/progress/json_test.go @@ -18,7 +18,6 @@ package progress import ( "bytes" - "context" "encoding/json" "testing" @@ -29,7 +28,6 @@ func TestJsonWriter_Event(t *testing.T) { var out bytes.Buffer w := &jsonWriter{ out: &out, - done: make(chan bool), dryRun: true, } @@ -60,30 +58,3 @@ func TestJsonWriter_Event(t *testing.T) { } assert.DeepEqual(t, expected, actual) } - -func TestJsonWriter_TailMsgf(t *testing.T) { - var out bytes.Buffer - w := &jsonWriter{ - out: &out, - done: make(chan bool), - dryRun: false, - } - - go func() { - _ = w.Start(context.Background()) - }() - - w.TailMsgf("hello %s", "world") - - w.Stop() - - var actual jsonMessage - err := json.Unmarshal(out.Bytes(), &actual) - assert.NilError(t, err) - - expected := jsonMessage{ - Tail: true, - Text: "hello world", - } - assert.DeepEqual(t, expected, actual) -} diff --git a/pkg/progress/mixed.go b/pkg/progress/mixed.go deleted file mode 100644 index d3180516a5f..00000000000 --- a/pkg/progress/mixed.go +++ /dev/null @@ -1,76 +0,0 @@ -/* - 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 progress - -import ( - "context" - "fmt" - - "github.com/docker/cli/cli/streams" - "github.com/docker/compose/v2/pkg/api" -) - -// NewMixedWriter creates a Writer which allows to mix output from progress.Writer with a api.LogConsumer -func NewMixedWriter(out *streams.Out, consumer api.LogConsumer, dryRun bool) Writer { - isTerminal := out.IsTerminal() - if Mode != ModeAuto || !isTerminal { - return &plainWriter{ - out: out, - done: make(chan bool), - dryRun: dryRun, - } - } - return &mixedWriter{ - out: consumer, - done: make(chan bool), - dryRun: dryRun, - } -} - -type mixedWriter struct { - done chan bool - dryRun bool - out api.LogConsumer -} - -func (p *mixedWriter) Start(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case <-p.done: - return nil - } -} - -func (p *mixedWriter) Event(e Event) { - p.out.Status("", fmt.Sprintf("%s %s %s", e.ID, e.Text, SuccessColor(e.StatusText))) -} - -func (p *mixedWriter) Events(events []Event) { - for _, e := range events { - p.Event(e) - } -} - -func (p *mixedWriter) TailMsgf(msg string, args ...interface{}) { - msg = fmt.Sprintf(msg, args...) - p.out.Status("", WarningColor(msg)) -} - -func (p *mixedWriter) Stop() { - p.done <- true -} diff --git a/pkg/progress/noop.go b/pkg/progress/noop.go deleted file mode 100644 index 5ef92b61171..00000000000 --- a/pkg/progress/noop.go +++ /dev/null @@ -1,39 +0,0 @@ -/* - 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 progress - -import ( - "context" -) - -type noopWriter struct{} - -func (p *noopWriter) Start(ctx context.Context) error { - return nil -} - -func (p *noopWriter) Event(Event) { -} - -func (p *noopWriter) Events([]Event) { -} - -func (p *noopWriter) TailMsgf(_ string, _ ...interface{}) { -} - -func (p *noopWriter) Stop() { -} diff --git a/pkg/progress/plain.go b/pkg/progress/plain.go index 308bcb36065..48129a46196 100644 --- a/pkg/progress/plain.go +++ b/pkg/progress/plain.go @@ -24,19 +24,18 @@ import ( "github.com/docker/compose/v2/pkg/api" ) +func NewPlainWriter(out io.Writer) EventProcessor { + return &plainWriter{ + out: out, + } +} + type plainWriter struct { out io.Writer - done chan bool dryRun bool } -func (p *plainWriter) Start(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case <-p.done: - return nil - } +func (p *plainWriter) Start(ctx context.Context, operation string) { } func (p *plainWriter) Event(e Event) { @@ -47,20 +46,11 @@ func (p *plainWriter) Event(e Event) { _, _ = fmt.Fprintln(p.out, prefix, e.ID, e.Text, e.StatusText) } -func (p *plainWriter) Events(events []Event) { +func (p *plainWriter) On(events ...Event) { for _, e := range events { p.Event(e) } } -func (p *plainWriter) TailMsgf(msg string, args ...interface{}) { - msg = fmt.Sprintf(msg, args...) - if p.dryRun { - msg = api.DRYRUN_PREFIX + msg - } - _, _ = fmt.Fprintln(p.out, msg) -} - -func (p *plainWriter) Stop() { - p.done <- true +func (p *plainWriter) Done(_ string, _ bool) { } diff --git a/pkg/progress/progress.go b/pkg/progress/progress.go new file mode 100644 index 00000000000..e7cc9af5de3 --- /dev/null +++ b/pkg/progress/progress.go @@ -0,0 +1,46 @@ +/* + 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 progress + +import ( + "context" +) + +type progressFunc func(context.Context) error + +func Run(ctx context.Context, pf progressFunc, operation string, bus EventProcessor) error { + bus.Start(ctx, operation) + err := pf(ctx) + bus.Done(operation, err != nil) + return err +} + +const ( + // ModeAuto detect console capabilities + ModeAuto = "auto" + // ModeTTY use terminal capability for advanced rendering + ModeTTY = "tty" + // ModePlain dump raw events to output + ModePlain = "plain" + // ModeQuiet don't display events + ModeQuiet = "quiet" + // ModeJSON outputs a machine-readable JSON stream + ModeJSON = "json" +) + +// Mode define how progress should be rendered, either as ModePlain or ModeTTY +var Mode = ModeAuto diff --git a/pkg/progress/quiet.go b/pkg/progress/quiet.go index 5c5530d76e0..31d146db9d8 100644 --- a/pkg/progress/quiet.go +++ b/pkg/progress/quiet.go @@ -18,20 +18,17 @@ package progress import "context" -type quiet struct{} - -func (q quiet) Start(_ context.Context) error { - return nil +func NewQuietWriter() EventProcessor { + return &quiet{} } -func (q quiet) Stop() { -} +type quiet struct{} -func (q quiet) Event(_ Event) { +func (q *quiet) Start(_ context.Context, _ string) { } -func (q quiet) Events(_ []Event) { +func (q *quiet) Done(_ string, _ bool) { } -func (q quiet) TailMsgf(_ string, _ ...interface{}) { +func (q *quiet) On(_ ...Event) { } diff --git a/pkg/progress/tty.go b/pkg/progress/tty.go index cc4388aa13a..5545be67bf9 100644 --- a/pkg/progress/tty.go +++ b/pkg/progress/tty.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "io" - "slices" "strings" "sync" "time" @@ -32,122 +31,173 @@ import ( "github.com/morikuni/aec" ) +// NewTTYWriter creates an EventProcessor that render advanced UI within a terminal. +// On Start, TUI lists task with a progress timer +func NewTTYWriter(out io.Writer) EventProcessor { + return &ttyWriter{ + out: out, + tasks: map[string]task{}, + done: make(chan bool), + mtx: &sync.Mutex{}, + } +} + type ttyWriter struct { out io.Writer - events map[string]Event - eventIDs []string + tasks map[string]task repeated bool numLines int done chan bool mtx *sync.Mutex - tailEvents []string - dryRun bool + dryRun bool // FIXME(ndeloof) (re)implement support for dry-run skipChildEvents bool - progressTitle string + operation string + ticker *time.Ticker } -func (w *ttyWriter) Start(ctx context.Context) error { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - w.print() - w.printTailEvents() - return ctx.Err() - case <-w.done: - w.print() - w.printTailEvents() - return nil - case <-ticker.C: - w.print() +type task struct { + ID string + parentID string + startTime time.Time + endTime time.Time + text string + status EventStatus + statusText string + current int64 + percent int + total int64 + spinner *Spinner +} + +func (t *task) stop() { + t.endTime = time.Now() + t.spinner.Stop() +} + +func (t *task) hasMore() { + t.spinner.Restart() +} + +func (w *ttyWriter) Start(ctx context.Context, operation string) { + w.ticker = time.NewTicker(100 * time.Millisecond) + w.operation = operation + go func() { + for { + select { + case <-ctx.Done(): + // interrupted + w.ticker.Stop() + return + case <-w.done: + w.print() + w.mtx.Lock() + w.ticker.Stop() + w.operation = "" + w.mtx.Unlock() + return + case <-w.ticker.C: + w.print() + } } - } + }() } -func (w *ttyWriter) Stop() { +func (w *ttyWriter) Done(operation string, success bool) { w.done <- true } -func (w *ttyWriter) Event(e Event) { +func (w *ttyWriter) On(events ...Event) { w.mtx.Lock() defer w.mtx.Unlock() - w.event(e) + for _, e := range events { + if w.operation != "start" && (e.StatusText == "Started" || e.StatusText == "Starting") { + // skip those events to avoid mix with container logs + continue + } + w.event(e) + } } func (w *ttyWriter) event(e Event) { - if !slices.Contains(w.eventIDs, e.ID) { - w.eventIDs = append(w.eventIDs, e.ID) + // Suspend print while a build is in progress, to avoid collision with buildkit Display + if e.StatusText == StatusBuilding { + w.ticker.Stop() + } else { + w.ticker.Reset(100 * time.Millisecond) } - if _, ok := w.events[e.ID]; ok { - last := w.events[e.ID] + + if last, ok := w.tasks[e.ID]; ok { switch e.Status { case Done, Error, Warning: - if last.Status != e.Status { + if last.status != e.Status { last.stop() } case Working: last.hasMore() } - last.Status = e.Status - last.Text = e.Text - last.StatusText = e.StatusText + last.status = e.Status + last.text = e.Text + last.statusText = e.StatusText // progress can only go up - if e.Total > last.Total { - last.Total = e.Total + if e.Total > last.total { + last.total = e.Total } - if e.Current > last.Current { - last.Current = e.Current + if e.Current > last.current { + last.current = e.Current } - if e.Percent > last.Percent { - last.Percent = e.Percent + if e.Percent > last.percent { + last.percent = e.Percent } // allow set/unset of parent, but not swapping otherwise prompt is flickering - if last.ParentID == "" || e.ParentID == "" { - last.ParentID = e.ParentID + if last.parentID == "" || e.ParentID == "" { + last.parentID = e.ParentID } - w.events[e.ID] = last + w.tasks[e.ID] = last } else { - e.startTime = time.Now() - e.spinner = NewSpinner() + t := task{ + ID: e.ID, + parentID: e.ParentID, + startTime: time.Now(), + text: e.Text, + status: e.Status, + statusText: e.StatusText, + current: e.Current, + percent: e.Percent, + total: e.Total, + spinner: NewSpinner(), + } if e.Status == Done || e.Status == Error { - e.stop() + t.stop() } - w.events[e.ID] = e + w.tasks[e.ID] = t } + w.printEvent(e) } -func (w *ttyWriter) Events(events []Event) { - w.mtx.Lock() - defer w.mtx.Unlock() - for _, e := range events { - w.event(e) - } -} - -func (w *ttyWriter) TailMsgf(msg string, args ...interface{}) { - w.mtx.Lock() - defer w.mtx.Unlock() - msgWithPrefix := msg - if w.dryRun { - msgWithPrefix = strings.TrimSpace(api.DRYRUN_PREFIX + msg) +func (w *ttyWriter) printEvent(e Event) { + if w.operation != "" { + // event will be displayed by progress UI on ticker's ticks + return } - w.tailEvents = append(w.tailEvents, fmt.Sprintf(msgWithPrefix, args...)) -} -func (w *ttyWriter) printTailEvents() { - w.mtx.Lock() - defer w.mtx.Unlock() - for _, msg := range w.tailEvents { - _, _ = fmt.Fprintln(w.out, msg) + var color colorFunc + switch e.Status { + case Working: + color = SuccessColor + case Done: + color = SuccessColor + case Warning: + color = WarningColor + case Error: + color = ErrorColor } + _, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, e.Text, color(e.StatusText)) } -func (w *ttyWriter) print() { //nolint:gocyclo +func (w *ttyWriter) print() { w.mtx.Lock() defer w.mtx.Unlock() - if len(w.eventIDs) == 0 { + if len(w.tasks) == 0 { return } terminalWidth := goterm.Width() @@ -167,43 +217,37 @@ func (w *ttyWriter) print() { //nolint:gocyclo _, _ = fmt.Fprint(w.out, aec.Show) }() - firstLine := fmt.Sprintf("[+] %s %d/%d", w.progressTitle, numDone(w.events), len(w.events)) - if w.numLines != 0 && numDone(w.events) == w.numLines { - firstLine = DoneColor(firstLine) - } + firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks)) _, _ = fmt.Fprintln(w.out, firstLine) var statusPadding int - for _, v := range w.eventIDs { - event := w.events[v] - l := len(fmt.Sprintf("%s %s", event.ID, event.Text)) + for _, t := range w.tasks { + l := len(fmt.Sprintf("%s %s", t.ID, t.text)) if statusPadding < l { statusPadding = l } - if event.ParentID != "" { + if t.parentID != "" { statusPadding -= 2 } } - if len(w.eventIDs) > goterm.Height()-2 { + if len(w.tasks) > goterm.Height()-2 { w.skipChildEvents = true } numLines := 0 - for _, v := range w.eventIDs { - event := w.events[v] - if event.ParentID != "" { + for _, t := range w.tasks { + if t.parentID != "" { continue } - line := w.lineText(event, "", terminalWidth, statusPadding, w.dryRun) + line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun) _, _ = fmt.Fprint(w.out, line) numLines++ - for _, v := range w.eventIDs { - ev := w.events[v] - if ev.ParentID == event.ID { + for _, t := range w.tasks { + if t.parentID == t.ID { if w.skipChildEvents { continue } - line := w.lineText(ev, " ", terminalWidth, statusPadding, w.dryRun) + line := w.lineText(t, " ", terminalWidth, statusPadding, w.dryRun) _, _ = fmt.Fprint(w.out, line) numLines++ } @@ -218,12 +262,12 @@ func (w *ttyWriter) print() { //nolint:gocyclo w.numLines = numLines } -func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPadding int, dryRun bool) string { +func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string { endTime := time.Now() - if event.Status != Working { - endTime = event.startTime - if (event.endTime != time.Time{}) { - endTime = event.endTime + if t.status != Working { + endTime = t.startTime + if (t.endTime != time.Time{}) { + endTime = t.endTime } } prefix := "" @@ -231,7 +275,7 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi prefix = PrefixColor(api.DRYRUN_PREFIX) } - elapsed := endTime.Sub(event.startTime).Seconds() + elapsed := endTime.Sub(t.startTime).Seconds() var ( hideDetails bool @@ -241,18 +285,17 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi ) // only show the aggregated progress while the root operation is in-progress - if parent := event; parent.Status == Working { - for _, v := range w.eventIDs { - child := w.events[v] - if child.ParentID == parent.ID { - if child.Status == Working && child.Total == 0 { + if parent := t; parent.status == Working { + for _, child := range w.tasks { + if child.parentID == parent.ID { + if child.status == Working && child.total == 0 { // we don't have totals available for all the child events // so don't show the total progress yet hideDetails = true } - total += child.Total - current += child.Current - completion = append(completion, percentChars[(len(percentChars)-1)*child.Percent/100]) + total += child.total + current += child.current + completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100]) } } } @@ -269,13 +312,13 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi details = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total))) } txt = fmt.Sprintf("%s [%s]%s %s", - event.ID, + t.ID, SuccessColor(strings.Join(completion, "")), details, - event.Text, + t.text, ) } else { - txt = fmt.Sprintf("%s %s", event.ID, event.Text) + txt = fmt.Sprintf("%s %s", t.ID, t.text) } textLen := len(txt) padding := statusPadding - textLen @@ -285,18 +328,18 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi // calculate the max length for the status text, on errors it // is 2-3 lines long and breaks the line formatting maxStatusLen := terminalWidth - textLen - statusPadding - 15 - status := event.StatusText + status := t.statusText // in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index if maxStatusLen > 0 && len(status) > maxStatusLen { status = status[:maxStatusLen] + "..." } text := fmt.Sprintf("%s %s%s %s%s %s", pad, - event.Spinner(), + spinner(t), prefix, txt, strings.Repeat(" ", padding), - event.Status.colorFn()(status), + colorFn(t.status)(status), ) timer := fmt.Sprintf("%.1fs ", elapsed) o := align(text, TimerColor(timer), terminalWidth) @@ -304,10 +347,42 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi return o } -func numDone(events map[string]Event) int { +var ( + spinnerDone = "✔" + spinnerWarning = "!" + spinnerError = "✘" +) + +func spinner(t task) string { + switch t.status { + case Done: + return SuccessColor(spinnerDone) + case Warning: + return WarningColor(spinnerWarning) + case Error: + return ErrorColor(spinnerError) + default: + return CountColor(t.spinner.String()) + } +} + +func colorFn(s EventStatus) colorFunc { + switch s { + case Done: + return SuccessColor + case Warning: + return WarningColor + case Error: + return ErrorColor + default: + return nocolor + } +} + +func numDone(tasks map[string]task) int { i := 0 - for _, e := range events { - if e.Status != Working { + for _, t := range tasks { + if t.status != Working { i++ } } diff --git a/pkg/progress/tty_test.go b/pkg/progress/tty_test.go deleted file mode 100644 index 3b8c9ba76b1..00000000000 --- a/pkg/progress/tty_test.go +++ /dev/null @@ -1,145 +0,0 @@ -/* - 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 progress - -import ( - "fmt" - "sync" - "testing" - "time" - - "gotest.tools/v3/assert" -) - -func TestLineText(t *testing.T) { - now := time.Now() - ev := Event{ - ID: "id", - Text: "Text", - Status: Working, - StatusText: "Status", - endTime: now, - startTime: now, - spinner: &Spinner{ - chars: []string{"."}, - }, - } - - lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text)) - - out := tty().lineText(ev, "", 50, lineWidth, false) - assert.Equal(t, out, " \x1b[33m.\x1b[0m id Text Status \x1b[34m0.0s \x1b[0m\n") - - ev.Status = Done - out = tty().lineText(ev, "", 50, lineWidth, false) - assert.Equal(t, out, " \x1b[32m✔\x1b[0m id Text \x1b[32mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") - - ev.Status = Error - out = tty().lineText(ev, "", 50, lineWidth, false) - assert.Equal(t, out, " \x1b[31m\x1b[1m✘\x1b[0m id Text \x1b[31m\x1b[1mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") - - ev.Status = Warning - out = tty().lineText(ev, "", 50, lineWidth, false) - assert.Equal(t, out, " \x1b[33m\x1b[1m!\x1b[0m id Text \x1b[33m\x1b[1mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") -} - -func TestLineTextSingleEvent(t *testing.T) { - now := time.Now() - ev := Event{ - ID: "id", - Text: "Text", - Status: Done, - StatusText: "Status", - startTime: now, - spinner: &Spinner{ - chars: []string{"."}, - }, - } - - lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text)) - - out := tty().lineText(ev, "", 50, lineWidth, false) - assert.Equal(t, out, " \x1b[32m✔\x1b[0m id Text \x1b[32mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") -} - -func TestErrorEvent(t *testing.T) { - w := &ttyWriter{ - events: map[string]Event{}, - mtx: &sync.Mutex{}, - } - e := Event{ - ID: "id", - Text: "Text", - Status: Working, - StatusText: "Working", - startTime: time.Now(), - spinner: &Spinner{ - chars: []string{"."}, - }, - } - // Fire "Working" event and check end time isn't touched - w.Event(e) - event, ok := w.events[e.ID] - assert.Assert(t, ok) - assert.Assert(t, event.endTime.Equal(time.Time{})) - - // Fire "Error" event and check end time is set - e.Status = Error - w.Event(e) - event, ok = w.events[e.ID] - assert.Assert(t, ok) - assert.Assert(t, event.endTime.After(time.Now().Add(-10*time.Second))) -} - -func TestWarningEvent(t *testing.T) { - w := &ttyWriter{ - events: map[string]Event{}, - mtx: &sync.Mutex{}, - } - e := Event{ - ID: "id", - Text: "Text", - Status: Working, - StatusText: "Working", - startTime: time.Now(), - spinner: &Spinner{ - chars: []string{"."}, - }, - } - // Fire "Working" event and check end time isn't touched - w.Event(e) - event, ok := w.events[e.ID] - assert.Assert(t, ok) - assert.Assert(t, event.endTime.Equal(time.Time{})) - - // Fire "Warning" event and check end time is set - e.Status = Warning - w.Event(e) - event, ok = w.events[e.ID] - assert.Assert(t, ok) - assert.Assert(t, event.endTime.After(time.Now().Add(-10*time.Second))) -} - -func tty() *ttyWriter { - tty := &ttyWriter{ - eventIDs: []string{}, - events: map[string]Event{}, - done: make(chan bool), - mtx: &sync.Mutex{}, - } - return tty -} diff --git a/pkg/progress/writer.go b/pkg/progress/writer.go deleted file mode 100644 index a9a6d091b2e..00000000000 --- a/pkg/progress/writer.go +++ /dev/null @@ -1,173 +0,0 @@ -/* - 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 progress - -import ( - "context" - "fmt" - "io" - "sync" - - "github.com/docker/cli/cli/streams" - "golang.org/x/sync/errgroup" - - "github.com/docker/compose/v2/pkg/api" -) - -// Writer can write multiple progress events -type Writer interface { - Start(context.Context) error - Stop() - Event(Event) - Events([]Event) - TailMsgf(string, ...interface{}) -} - -type writerKey struct{} - -// WithContextWriter adds the writer to the context -func WithContextWriter(ctx context.Context, writer Writer) context.Context { - return context.WithValue(ctx, writerKey{}, writer) -} - -// ContextWriter returns the writer from the context -func ContextWriter(ctx context.Context) Writer { - s, ok := ctx.Value(writerKey{}).(Writer) - if !ok { - return &noopWriter{} - } - return s -} - -type progressFunc func(context.Context) error - -type progressFuncWithStatus func(context.Context) (string, error) - -// Run will run a writer and the progress function in parallel -func Run(ctx context.Context, pf progressFunc, out *streams.Out) error { - _, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) { - return "", pf(ctx) - }, out, "Running") - return err -} - -func RunWithLog(ctx context.Context, pf progressFunc, out *streams.Out, logConsumer api.LogConsumer) error { - w := NewMixedWriter(out, logConsumer, false) // FIXME(ndeloof) re-implement dry-run - eg, _ := errgroup.WithContext(ctx) - eg.Go(func() error { - return w.Start(context.Background()) - }) - eg.Go(func() error { - defer w.Stop() - ctx = WithContextWriter(ctx, w) - err := pf(ctx) - return err - }) - return eg.Wait() -} - -func RunWithTitle(ctx context.Context, pf progressFunc, out *streams.Out, progressTitle string) error { - _, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) { - return "", pf(ctx) - }, out, progressTitle) - return err -} - -// RunWithStatus will run a writer and the progress function in parallel and return a status -func RunWithStatus(ctx context.Context, pf progressFuncWithStatus, out *streams.Out, progressTitle string) (string, error) { - eg, _ := errgroup.WithContext(ctx) - w, err := NewWriter(ctx, out, progressTitle) - var result string - if err != nil { - return "", err - } - eg.Go(func() error { - return w.Start(context.Background()) - }) - - ctx = WithContextWriter(ctx, w) - - eg.Go(func() error { - defer w.Stop() - s, err := pf(ctx) - if err == nil { - result = s - } - return err - }) - - err = eg.Wait() - return result, err -} - -const ( - // ModeAuto detect console capabilities - ModeAuto = "auto" - // ModeTTY use terminal capability for advanced rendering - ModeTTY = "tty" - // ModePlain dump raw events to output - ModePlain = "plain" - // ModeQuiet don't display events - ModeQuiet = "quiet" - // ModeJSON outputs a machine-readable JSON stream - ModeJSON = "json" -) - -// Mode define how progress should be rendered, either as ModePlain or ModeTTY -var Mode = ModeAuto - -// NewWriter returns a new multi-progress writer -func NewWriter(ctx context.Context, out *streams.Out, progressTitle string) (Writer, error) { - isTerminal := out.IsTerminal() - switch Mode { - case ModeQuiet: - return quiet{}, nil - case ModeJSON: - return &jsonWriter{ - out: out, - done: make(chan bool), - dryRun: false, // FIXME(ndeloof) re-implement dry-run - }, nil - case ModeTTY: - return newTTYWriter(out, false, progressTitle) - case ModeAuto, "": - if isTerminal { - return newTTYWriter(out, false, progressTitle) - } - fallthrough - case ModePlain: - return &plainWriter{ - out: out, - done: make(chan bool), - dryRun: false, - }, nil - } - return nil, fmt.Errorf("unknown progress mode: %s", Mode) -} - -func newTTYWriter(out io.Writer, dryRun bool, progressTitle string) (Writer, error) { - return &ttyWriter{ - out: out, - eventIDs: []string{}, - events: map[string]Event{}, - repeated: false, - done: make(chan bool), - mtx: &sync.Mutex{}, - dryRun: dryRun, - progressTitle: progressTitle, - }, nil -} diff --git a/pkg/progress/writer_test.go b/pkg/progress/writer_test.go deleted file mode 100644 index 2933811b06d..00000000000 --- a/pkg/progress/writer_test.go +++ /dev/null @@ -1,31 +0,0 @@ -/* - 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 progress - -import ( - "context" - "testing" - - "gotest.tools/v3/assert" -) - -func TestNoopWriter(t *testing.T) { - todo := context.TODO() - writer := ContextWriter(todo) - - assert.Equal(t, writer, &noopWriter{}) -}