move progress UI components into cmd

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof
2025-11-05 17:08:53 +01:00
committed by Guillaume Lours
parent 5ef495c898
commit aff5c115d6
45 changed files with 619 additions and 596 deletions

View File

@@ -35,12 +35,7 @@ var (
ErrForbidden = errors.New("forbidden")
// ErrUnknown is returned when the error type is unmapped
ErrUnknown = errors.New("unknown")
// ErrLoginFailed is returned when login failed
ErrLoginFailed = errors.New("login failed")
// ErrLoginRequired is returned when login is required for a specific action
ErrLoginRequired = errors.New("login required")
// ErrNotImplemented is returned when a backend doesn't implement
// an action
// ErrNotImplemented is returned when a backend doesn't implement an action
ErrNotImplemented = errors.New("not implemented")
// ErrUnsupportedFlag is returned when a backend doesn't support a flag
ErrUnsupportedFlag = errors.New("unsupported flag")
@@ -48,9 +43,8 @@ var (
ErrCanceled = errors.New("canceled")
// ErrParsingFailed is returned when a string cannot be parsed
ErrParsingFailed = errors.New("parsing failed")
// ErrWrongContextType is returned when the caller tries to get a context
// with the wrong type
ErrWrongContextType = errors.New("wrong context type")
// ErrNoResources is returned when operation didn't selected any resource
ErrNoResources = errors.New("no resources")
)
// IsNotFoundError returns true if the unwrapped error is ErrNotFound

103
pkg/api/event.go Normal file
View File

@@ -0,0 +1,103 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
import (
"context"
)
// EventStatus indicates the status of an action
type EventStatus int
const (
// Working means that the current task is working
Working EventStatus = iota
// Done means that the current task is done
Done
// Warning means that the current task has warning
Warning
// Error means that the current task has errored
Error
)
// ResourceCompose is a special resource ID used when event applies to all resources in the application
const ResourceCompose = "Compose"
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"
StatusCommitting = "Committing"
StatusCommitted = "Committed"
StatusCopying = "Copying"
StatusCopied = "Copied"
StatusExporting = "Exporting"
StatusExported = "Exported"
)
// Resource represents status change and progress for a compose resource.
type Resource struct {
ID string
ParentID string
Text string
Details string
Status EventStatus
Current int64
Percent int
Total int64
}
func (e *Resource) StatusText() string {
switch e.Status {
case Working:
return "Working"
case Warning:
return "Warning"
case Done:
return "Done"
default:
return "Error"
}
}
// 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 ...Resource)
// Done is triggered as a Compose operation completed
Done(operation string, success bool)
}

View File

@@ -26,7 +26,6 @@ import (
"github.com/containerd/platforms"
"github.com/docker/compose/v2/internal/tracing"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/compose/v2/pkg/utils"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
@@ -37,7 +36,7 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti
if err != nil {
return err
}
return progress.Run(ctx, func(ctx context.Context) error {
return 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)

View File

@@ -40,7 +40,6 @@ import (
"github.com/docker/cli/cli/command/image/build"
"github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/versions"
"github.com/google/uuid"
"github.com/moby/buildkit/client"
@@ -118,10 +117,10 @@ type buildStatus struct {
func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo
eg := errgroup.Group{}
ch := make(chan *client.SolveStatus)
if options.Progress == progress.ModeAuto {
displayMode := progressui.DisplayMode(options.Progress)
if displayMode == progressui.AutoMode {
options.Progress = os.Getenv("BUILDKIT_PROGRESS")
}
displayMode := progressui.DisplayMode(options.Progress)
out := options.Out
if out == nil {
out = s.stdout()
@@ -206,7 +205,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
}
image := api.GetImageNameOrDefault(service, project.Name)
s.events.On(progress.BuildingEvent(image))
s.events.On(buildingEvent(image))
expectedImages[serviceName] = image
@@ -408,7 +407,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
s.events.On(progress.BuiltEvent(image))
s.events.On(builtEvent(image))
}
return results, nil
}
@@ -554,20 +553,20 @@ func (s composeService) dryRunBake(cfg bakeConfig) map[string]string {
bakeResponse[name] = dryRunUUID
}
for name := range bakeResponse {
s.events.On(progress.BuiltEvent(name))
s.events.On(builtEvent(name))
}
return bakeResponse
}
func (s composeService) displayDryRunBuildEvent(name, dryRunUUID, tag string) {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name + " ==>",
Status: progress.Done,
Status: api.Done,
Text: fmt.Sprintf("==> writing image %s", dryRunUUID),
})
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name + " ==> ==>",
Status: progress.Done,
Status: api.Done,
Text: fmt.Sprintf(`naming to %s`, tag),
})
}

View File

@@ -30,7 +30,6 @@ 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"
@@ -86,12 +85,12 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj
}
image := api.GetImageNameOrDefault(service, project.Name)
s.events.On(progress2.BuildingEvent(image))
s.events.On(buildingEvent(image))
id, err := s.doBuildImage(ctx, project, service, options)
if err != nil {
return err
}
s.events.On(progress2.BuiltEvent(image))
s.events.On(builtEvent(image))
builtDigests[getServiceIndex(name)] = id
if options.Push {
@@ -258,7 +257,7 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec
ctx, cancel := context.WithCancel(ctx)
defer cancel()
s.events.On(progress2.BuildingEvent(imageName))
s.events.On(buildingEvent(imageName))
response, err := s.apiClient().ImageBuild(ctx, body, buildOpts)
if err != nil {
return "", err
@@ -287,7 +286,7 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec
}
return "", err
}
s.events.On(progress2.BuiltEvent(imageName))
s.events.On(builtEvent(imageName))
return imageID, nil
}

View File

@@ -22,12 +22,11 @@ import (
"strings"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/api/types/container"
)
func (s *composeService) Commit(ctx context.Context, projectName string, options api.CommitOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.commit(ctx, projectName, options)
}, "commit", s.events)
}
@@ -42,17 +41,17 @@ func (s *composeService) commit(ctx context.Context, projectName string, options
name := getCanonicalContainerName(ctr)
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Status: progress.Working,
Text: progress.StatusCommitting,
Status: api.Working,
Text: api.StatusCommitting,
})
if s.dryRun {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Status: progress.Done,
Text: progress.StatusCommitted,
Status: api.Done,
Text: api.StatusCommitted,
})
return nil
@@ -69,10 +68,10 @@ func (s *composeService) commit(ctx context.Context, projectName string, options
return err
}
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Text: fmt.Sprintf("Committed as %s", response.ID),
Status: progress.Done,
Status: api.Done,
})
return nil

View File

@@ -21,7 +21,6 @@ import (
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"
@@ -32,7 +31,6 @@ 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"
@@ -45,15 +43,6 @@ import (
"github.com/docker/compose/v2/pkg/api"
)
var stdioToStdout bool
func init() {
out, ok := os.LookupEnv("COMPOSE_STATUS_STDOUT")
if ok {
stdioToStdout, _ = strconv.ParseBool(out)
}
}
type Option func(service *composeService) error
// NewComposeService creates a Compose service using Docker CLI.
@@ -96,7 +85,7 @@ func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, e
}
}
if s.events == nil {
s.events = progress.NewQuietWriter()
s.events = &ignore{}
}
// If custom streams were provided, wrap the Docker CLI to use them
@@ -204,7 +193,7 @@ func AlwaysOkPrompt() Prompt {
// 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 {
func WithEventProcessor(bus api.EventProcessor) Option {
return func(s *composeService) error {
s.events = bus
return nil
@@ -216,7 +205,7 @@ type composeService struct {
// prompt is used to interact with user and confirm actions
prompt Prompt
// eventBus collects tasks execution events
events progress.EventProcessor
events api.EventProcessor
// Optional overrides for specific components (for SDK users)
outStream io.Writer
@@ -278,13 +267,6 @@ func (s *composeService) stderr() *streams.Out {
return s.dockerCli.Err()
}
func (s *composeService) stdinfo() *streams.Out {
if stdioToStdout {
return s.stdout()
}
return s.stderr()
}
// readCloserAdapter adapts io.Reader to io.ReadCloser
type readCloserAdapter struct {
r io.Reader

View File

@@ -41,7 +41,6 @@ import (
"github.com/docker/compose/v2/internal/tracing"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/compose/v2/pkg/utils"
)
@@ -187,7 +186,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
name := getContainerProgressName(ctr)
switch ctr.State {
case container.StateRunning:
c.compose.events.On(progress.RunningEvent(name))
c.compose.events.On(runningEvent(name))
case container.StateCreated:
case container.StateRestarting:
case container.StateExited:
@@ -426,16 +425,16 @@ func getContainerProgressName(ctr container.Summary) string {
return "Container " + getCanonicalContainerName(ctr)
}
func containerEvents(containers Containers, eventFunc func(string) progress.Event) []progress.Event {
events := []progress.Event{}
func containerEvents(containers Containers, eventFunc func(string) api.Resource) []api.Resource {
events := []api.Resource{}
for _, ctr := range containers {
events = append(events, eventFunc(getContainerProgressName(ctr)))
}
return events
}
func containerReasonEvents(containers Containers, eventFunc func(string, string) progress.Event, reason string) []progress.Event {
events := []progress.Event{}
func containerReasonEvents(containers Containers, eventFunc func(string, string) api.Resource, reason string) []api.Resource {
events := []api.Resource{}
for _, ctr := range containers {
events = append(events, eventFunc(getContainerProgressName(ctr), reason))
}
@@ -461,7 +460,7 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
}
waitingFor := containers.filter(isService(dep), isNotOneOff)
s.events.On(containerEvents(waitingFor, progress.Waiting)...)
s.events.On(containerEvents(waitingFor, waiting)...)
if len(waitingFor) == 0 {
if config.Required {
return fmt.Errorf("%s is missing dependency %s", dependant, dep)
@@ -481,61 +480,61 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
}
switch config.Condition {
case ServiceConditionRunningOrHealthy:
healthy, err := s.isServiceHealthy(ctx, waitingFor, true)
isHealthy, err := s.isServiceHealthy(ctx, waitingFor, true)
if err != nil {
if !config.Required {
s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent,
s.events.On(containerReasonEvents(waitingFor, 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 {
s.events.On(containerEvents(waitingFor, progress.Healthy)...)
if isHealthy {
s.events.On(containerEvents(waitingFor, healthy)...)
return nil
}
case types.ServiceConditionHealthy:
healthy, err := s.isServiceHealthy(ctx, waitingFor, false)
isHealthy, err := s.isServiceHealthy(ctx, waitingFor, false)
if err != nil {
if !config.Required {
s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent,
s.events.On(containerReasonEvents(waitingFor, skippedEvent,
fmt.Sprintf("optional dependency %q failed to start", dep))...)
logrus.Warnf("optional dependency %q failed to start: %s", dep, err.Error())
return nil
}
s.events.On(containerEvents(waitingFor, func(s string) progress.Event {
return progress.ErrorEventf(s, "dependency %s failed to start", dep)
s.events.On(containerEvents(waitingFor, func(s string) api.Resource {
return errorEventf(s, "dependency %s failed to start", dep)
})...)
return fmt.Errorf("dependency failed to start: %w", err)
}
if healthy {
s.events.On(containerEvents(waitingFor, progress.Healthy)...)
if isHealthy {
s.events.On(containerEvents(waitingFor, healthy)...)
return nil
}
case types.ServiceConditionCompletedSuccessfully:
exited, code, err := s.isServiceCompleted(ctx, waitingFor)
isExited, code, err := s.isServiceCompleted(ctx, waitingFor)
if err != nil {
return err
}
if exited {
if isExited {
if code == 0 {
s.events.On(containerEvents(waitingFor, progress.Exited)...)
s.events.On(containerEvents(waitingFor, 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
s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent,
s.events.On(containerReasonEvents(waitingFor, skippedEvent,
fmt.Sprintf("optional dependency %s", messageSuffix))...)
logrus.Warnf("optional dependency %s", messageSuffix)
return nil
}
msg := fmt.Sprintf("service %s", messageSuffix)
s.events.On(containerEvents(waitingFor, func(s string) progress.Event {
return progress.ErrorEventf(s, "service %s", messageSuffix)
s.events.On(containerEvents(waitingFor, func(s string) api.Resource {
return errorEventf(s, "service %s", messageSuffix)
})...)
return errors.New(msg)
}
@@ -599,19 +598,19 @@ func (s *composeService) createContainer(ctx context.Context, project *types.Pro
name string, number int, opts createOptions,
) (ctr container.Summary, err error) {
eventName := "Container " + name
s.events.On(progress.CreatingEvent(eventName))
s.events.On(creatingEvent(eventName))
ctr, err = s.createMobyContainer(ctx, project, service, name, number, nil, opts)
if err != nil {
if ctx.Err() == nil {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: eventName,
Status: progress.Error,
Status: api.Error,
Text: err.Error(),
})
}
return ctr, err
}
s.events.On(progress.CreatedEvent(eventName))
s.events.On(createdEvent(eventName))
return ctr, nil
}
@@ -619,12 +618,12 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P
replaced container.Summary, inherit bool, timeout *time.Duration,
) (created container.Summary, err error) {
eventName := getContainerProgressName(replaced)
s.events.On(progress.NewEvent(eventName, progress.Working, "Recreate"))
s.events.On(newEvent(eventName, api.Working, "Recreate"))
defer func() {
if err != nil && ctx.Err() == nil {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: eventName,
Status: progress.Error,
Status: api.Error,
Text: err.Error(),
})
}
@@ -673,7 +672,7 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P
return created, err
}
s.events.On(progress.NewEvent(eventName, progress.Done, "Recreated"))
s.events.On(newEvent(eventName, api.Done, "Recreated"))
return created, err
}
@@ -681,14 +680,14 @@ 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 {
s.events.On(progress.NewEvent(getContainerProgressName(ctr), progress.Working, "Restart"))
s.events.On(newEvent(getContainerProgressName(ctr), api.Working, "Restart"))
startMx.Lock()
defer startMx.Unlock()
err := s.apiClient().ContainerStart(ctx, ctr.ID, container.StartOptions{})
if err != nil {
return err
}
s.events.On(progress.NewEvent(getContainerProgressName(ctr), progress.Done, "Restarted"))
s.events.On(newEvent(getContainerProgressName(ctr), api.Done, "Restarted"))
return nil
}
@@ -719,9 +718,9 @@ func (s *composeService) createMobyContainer(ctx context.Context, project *types
return created, err
}
for _, warning := range response.Warnings {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: service.Name,
Status: progress.Warning,
Status: api.Warning,
Text: warning,
})
}
@@ -906,7 +905,7 @@ func (s *composeService) startService(ctx context.Context,
}
eventName := getContainerProgressName(ctr)
s.events.On(progress.StartingEvent(eventName))
s.events.On(startingEvent(eventName))
err = s.apiClient().ContainerStart(ctx, ctr.ID, container.StartOptions{})
if err != nil {
return err
@@ -919,7 +918,7 @@ func (s *composeService) startService(ctx context.Context,
}
}
s.events.On(progress.StartedEvent(eventName))
s.events.On(startedEvent(eventName))
}
return nil
}

View File

@@ -25,7 +25,6 @@ import (
"path/filepath"
"strings"
"github.com/docker/compose/v2/pkg/progress"
"golang.org/x/sync/errgroup"
"github.com/docker/cli/cli/command"
@@ -43,7 +42,7 @@ const (
)
func (s *composeService) Copy(ctx context.Context, projectName string, options api.CopyOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.copy(ctx, projectName, options)
}, "copy", s.events)
}
@@ -90,20 +89,20 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a
} else {
msg = fmt.Sprintf("%s to %s:%s", srcPath, name, dstPath)
}
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Text: progress.StatusCopying,
Text: api.StatusCopying,
Details: msg,
Status: progress.Working,
Status: api.Working,
})
if err := copyFunc(ctx, ctr.ID, srcPath, dstPath, options); err != nil {
return err
}
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Text: progress.StatusCopied,
Text: api.StatusCopied,
Details: msg,
Status: progress.Done,
Status: api.Done,
})
return nil
})

View File

@@ -43,7 +43,6 @@ import (
cdi "tags.cncf.io/container-device-interface/pkg/parser"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
)
type createOptions struct {
@@ -61,7 +60,7 @@ type createConfigs struct {
}
func (s *composeService) Create(ctx context.Context, project *types.Project, createOpts api.CreateOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.create(ctx, project, createOpts)
}, "create", s.events)
}
@@ -1394,14 +1393,14 @@ func (s *composeService) resolveOrCreateNetwork(ctx context.Context, project *ty
}
networkEventName := fmt.Sprintf("Network %s", n.Name)
s.events.On(progress.CreatingEvent(networkEventName))
s.events.On(creatingEvent(networkEventName))
resp, err := s.apiClient().NetworkCreate(ctx, n.Name, createOpts)
if err != nil {
s.events.On(progress.ErrorEvent(networkEventName, err.Error()))
s.events.On(errorEvent(networkEventName, err.Error()))
return "", fmt.Errorf("failed to create network %s: %w", n.Name, err)
}
s.events.On(progress.CreatedEvent(networkEventName))
s.events.On(createdEvent(networkEventName))
err = s.connectNetwork(ctx, n.Name, dangledContainers, nil)
if err != nil {
@@ -1443,7 +1442,7 @@ func (s *composeService) removeDivergedNetwork(ctx context.Context, project *typ
err = s.apiClient().NetworkRemove(ctx, n.Name)
eventName := fmt.Sprintf("Network %s", n.Name)
s.events.On(progress.RemovedEvent(eventName))
s.events.On(removedEvent(eventName))
return containers, err
}
@@ -1619,7 +1618,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)
s.events.On(progress.CreatingEvent(eventName))
s.events.On(creatingEvent(eventName))
hash, err := VolumeHash(volume)
if err != nil {
return err
@@ -1632,9 +1631,9 @@ func (s *composeService) createVolume(ctx context.Context, volume types.VolumeCo
DriverOpts: volume.DriverOpts,
})
if err != nil {
s.events.On(progress.ErrorEvent(eventName, err.Error()))
s.events.On(errorEvent(eventName, err.Error()))
return err
}
s.events.On(progress.CreatedEvent(eventName))
s.events.On(createdEvent(eventName))
return nil
}

View File

@@ -25,7 +25,6 @@ import (
"github.com/compose-spec/compose-go/v2/types"
"github.com/containerd/errdefs"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/compose/v2/pkg/utils"
containerType "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
@@ -38,7 +37,7 @@ import (
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 Run(ctx, func(ctx context.Context) error {
return s.down(ctx, strings.ToLower(projectName), options)
}, "down", s.events)
}
@@ -210,7 +209,7 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
}
eventName := fmt.Sprintf("Network %s", name)
s.events.On(progress.RemovingEvent(eventName))
s.events.On(removingEvent(eventName))
var found int
for _, net := range networks {
@@ -219,14 +218,14 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
}
nw, err := s.apiClient().NetworkInspect(ctx, net.ID, network.InspectOptions{})
if errdefs.IsNotFound(err) {
s.events.On(progress.NewEvent(eventName, progress.Warning, "No resource found to remove"))
s.events.On(newEvent(eventName, api.Warning, "No resource found to remove"))
return nil
}
if err != nil {
return err
}
if len(nw.Containers) > 0 {
s.events.On(progress.NewEvent(eventName, progress.Warning, "Resource is still in use"))
s.events.On(newEvent(eventName, api.Warning, "Resource is still in use"))
found++
continue
}
@@ -235,10 +234,10 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
if errdefs.IsNotFound(err) {
continue
}
s.events.On(progress.ErrorEvent(eventName, err.Error()))
s.events.On(errorEvent(eventName, err.Error()))
return fmt.Errorf("failed to remove network %s: %w", name, err)
}
s.events.On(progress.RemovedEvent(eventName))
s.events.On(removedEvent(eventName))
found++
}
@@ -246,7 +245,7 @@ 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
s.events.On(progress.NewEvent(eventName, progress.Warning, "No resource found to remove"))
s.events.On(newEvent(eventName, api.Warning, "No resource found to remove"))
return nil
}
return nil
@@ -254,18 +253,18 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
func (s *composeService) removeImage(ctx context.Context, image string) error {
id := fmt.Sprintf("Image %s", image)
s.events.On(progress.NewEvent(id, progress.Working, "Removing"))
s.events.On(newEvent(id, api.Working, "Removing"))
_, err := s.apiClient().ImageRemove(ctx, image, imageapi.RemoveOptions{})
if err == nil {
s.events.On(progress.NewEvent(id, progress.Done, "Removed"))
s.events.On(newEvent(id, api.Done, "Removed"))
return nil
}
if errdefs.IsConflict(err) {
s.events.On(progress.NewEvent(id, progress.Warning, "Resource is still in use"))
s.events.On(newEvent(id, api.Warning, "Resource is still in use"))
return nil
}
if errdefs.IsNotFound(err) {
s.events.On(progress.NewEvent(id, progress.Done, "Warning: No resource found to remove"))
s.events.On(newEvent(id, api.Done, "Warning: No resource found to remove"))
return nil
}
return err
@@ -280,18 +279,18 @@ func (s *composeService) removeVolume(ctx context.Context, id string) error {
return nil
}
s.events.On(progress.NewEvent(resource, progress.Working, "Removing"))
s.events.On(newEvent(resource, api.Working, "Removing"))
err = s.apiClient().VolumeRemove(ctx, id, true)
if err == nil {
s.events.On(progress.NewEvent(resource, progress.Done, "Removed"))
s.events.On(newEvent(resource, api.Done, "Removed"))
return nil
}
if errdefs.IsConflict(err) {
s.events.On(progress.NewEvent(resource, progress.Warning, "Resource is still in use"))
s.events.On(newEvent(resource, api.Warning, "Resource is still in use"))
return nil
}
if errdefs.IsNotFound(err) {
s.events.On(progress.NewEvent(resource, progress.Done, "Warning: No resource found to remove"))
s.events.On(newEvent(resource, api.Done, "Warning: No resource found to remove"))
return nil
}
return err
@@ -299,7 +298,7 @@ func (s *composeService) removeVolume(ctx context.Context, id string) error {
func (s *composeService) stopContainer(ctx context.Context, service *types.ServiceConfig, ctr containerType.Summary, timeout *time.Duration, listener api.ContainerEventListener) error {
eventName := getContainerProgressName(ctr)
s.events.On(progress.StoppingEvent(eventName))
s.events.On(stoppingEvent(eventName))
if service != nil {
for _, hook := range service.PreStop {
@@ -317,10 +316,10 @@ func (s *composeService) stopContainer(ctx context.Context, service *types.Servi
timeoutInSecond := utils.DurationSecondToInt(timeout)
err := s.apiClient().ContainerStop(ctx, ctr.ID, containerType.StopOptions{Timeout: timeoutInSecond})
if err != nil {
s.events.On(progress.ErrorEvent(eventName, "Error while Stopping"))
s.events.On(errorEvent(eventName, "Error while Stopping"))
return err
}
s.events.On(progress.StoppedEvent(eventName))
s.events.On(stoppedEvent(eventName))
return nil
}
@@ -348,22 +347,22 @@ func (s *composeService) stopAndRemoveContainer(ctx context.Context, ctr contain
eventName := getContainerProgressName(ctr)
err := s.stopContainer(ctx, service, ctr, timeout, nil)
if errdefs.IsNotFound(err) {
s.events.On(progress.RemovedEvent(eventName))
s.events.On(removedEvent(eventName))
return nil
}
if err != nil {
return err
}
s.events.On(progress.RemovingEvent(eventName))
s.events.On(removingEvent(eventName))
err = s.apiClient().ContainerRemove(ctx, ctr.ID, containerType.RemoveOptions{
Force: true,
RemoveVolumes: volumes,
})
if err != nil && !errdefs.IsNotFound(err) && !errdefs.IsConflict(err) {
s.events.On(progress.ErrorEvent(eventName, "Error while Removing"))
s.events.On(errorEvent(eventName, "Error while Removing"))
return err
}
s.events.On(progress.RemovedEvent(eventName))
s.events.On(removedEvent(eventName))
return nil
}

View File

@@ -24,12 +24,11 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/moby/sys/atomicwriter"
)
func (s *composeService) Export(ctx context.Context, projectName string, options api.ExportOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.export(ctx, projectName, options)
}, "export", s.events)
}
@@ -51,10 +50,10 @@ func (s *composeService) export(ctx context.Context, projectName string, options
}
name := getCanonicalContainerName(container)
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Text: progress.StatusExporting,
Status: progress.Working,
Text: api.StatusExporting,
Status: api.Working,
})
responseBody, err := s.apiClient().ContainerExport(ctx, container.ID)
@@ -64,7 +63,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options
defer func() {
if err := responseBody.Close(); err != nil {
s.events.On(progress.ErrorEventf(name, "Failed to close response body: %s", err.Error()))
s.events.On(errorEventf(name, "Failed to close response body: %s", err.Error()))
}
}()
@@ -84,10 +83,10 @@ func (s *composeService) export(ctx context.Context, projectName string, options
}
}
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Text: progress.StatusExported,
Status: progress.Done,
Text: api.StatusExported,
Status: api.Done,
})
return nil

View File

@@ -18,18 +18,16 @@ package compose
import (
"context"
"fmt"
"strings"
"github.com/docker/docker/api/types/container"
"golang.org/x/sync/errgroup"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
)
func (s *composeService) Kill(ctx context.Context, projectName string, options api.KillOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.kill(ctx, strings.ToLower(projectName), options)
}, "kill", s.events)
}
@@ -55,21 +53,20 @@ func (s *composeService) kill(ctx context.Context, projectName string, options a
containers = containers.filter(isService(project.ServiceNames()...))
}
if len(containers) == 0 {
_, _ = fmt.Fprintf(s.stdinfo(), "no container to kill")
return nil
return api.ErrNoResources
}
eg, ctx := errgroup.WithContext(ctx)
containers.forEach(func(ctr container.Summary) {
eg.Go(func() error {
eventName := getContainerProgressName(ctr)
s.events.On(progress.KillingEvent(eventName))
s.events.On(killingEvent(eventName))
err := s.apiClient().ContainerKill(ctx, ctr.ID, options.Signal)
if err != nil {
s.events.On(progress.ErrorEvent(eventName, "Error while Killing"))
s.events.On(errorEvent(eventName, "Error while Killing"))
return err
}
s.events.On(progress.KilledEvent(eventName))
s.events.On(killedEvent(eventName))
return nil
})
})

View File

@@ -29,7 +29,7 @@ import (
"github.com/compose-spec/compose-go/v2/types"
"github.com/containerd/errdefs"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/compose/v2/pkg/api"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
)
@@ -101,10 +101,10 @@ func (m *modelAPI) Close() {
m.cleanup()
}
func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quietPull bool, events progress.EventProcessor) error {
events.On(progress.Event{
func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quietPull bool, events api.EventProcessor) error {
events.On(api.Resource{
ID: model.Name,
Status: progress.Working,
Status: api.Working,
Text: "Pulling",
})
@@ -131,30 +131,30 @@ func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quiet
}
if !quietPull {
events.On(progress.Event{
events.On(api.Resource{
ID: model.Name,
Status: progress.Working,
Text: progress.StatusPulling,
Status: api.Working,
Text: api.StatusPulling,
})
}
}
err = cmd.Wait()
if err != nil {
events.On(progress.ErrorEvent(model.Name, err.Error()))
events.On(errorEvent(model.Name, err.Error()))
}
events.On(progress.Event{
events.On(api.Resource{
ID: model.Name,
Status: progress.Working,
Text: progress.StatusPulled,
Status: api.Working,
Text: api.StatusPulled,
})
return err
}
func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, events progress.EventProcessor) error {
events.On(progress.Event{
func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, events api.EventProcessor) error {
events.On(api.Resource{
ID: config.Name,
Status: progress.Working,
Status: api.Working,
Text: "Configuring",
})
// configure [--context-size=<n>] MODEL [-- <runtime-flags...>]

View File

@@ -24,11 +24,10 @@ import (
"golang.org/x/sync/errgroup"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
)
func (s *composeService) Pause(ctx context.Context, projectName string, options api.PauseOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.pause(ctx, strings.ToLower(projectName), options)
}, "pause", s.events)
}
@@ -49,7 +48,7 @@ func (s *composeService) pause(ctx context.Context, projectName string, options
err := s.apiClient().ContainerPause(ctx, container.ID)
if err == nil {
eventName := getContainerProgressName(container)
s.events.On(progress.NewEvent(eventName, progress.Done, "Paused"))
s.events.On(newEvent(eventName, api.Done, "Paused"))
}
return err
})
@@ -58,7 +57,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 Run(ctx, func(ctx context.Context) error {
return s.unPause(ctx, strings.ToLower(projectName), options)
}, "unpause", s.events)
}
@@ -79,7 +78,7 @@ func (s *composeService) unPause(ctx context.Context, projectName string, option
err = s.apiClient().ContainerUnpause(ctx, ctr.ID)
if err == nil {
eventName := getContainerProgressName(ctr)
s.events.On(progress.NewEvent(eventName, progress.Done, "Unpaused"))
s.events.On(newEvent(eventName, api.Done, "Unpaused"))
}
return err
})

View File

@@ -33,7 +33,7 @@ import (
"github.com/containerd/errdefs"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/config"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/compose/v2/pkg/api"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@@ -89,10 +89,10 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
var action string
switch command {
case "up":
s.events.On(progress.CreatingEvent(service.Name))
s.events.On(creatingEvent(service.Name))
action = "create"
case "down":
s.events.On(progress.RemovingEvent(service.Name))
s.events.On(removingEvent(service.Name))
action = "remove"
default:
return nil, fmt.Errorf("unsupported plugin command: %s", command)
@@ -124,10 +124,10 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
}
switch msg.Type {
case ErrorType:
s.events.On(progress.NewEvent(service.Name, progress.Error, msg.Message))
s.events.On(newEvent(service.Name, api.Error, msg.Message))
return nil, errors.New(msg.Message)
case InfoType:
s.events.On(progress.NewEvent(service.Name, progress.Working, msg.Message))
s.events.On(newEvent(service.Name, api.Working, msg.Message))
case SetEnvType:
key, val, found := strings.Cut(msg.Message, "=")
if !found {
@@ -143,14 +143,14 @@ func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service ty
err = cmd.Wait()
if err != nil {
s.events.On(progress.ErrorEvent(service.Name, err.Error()))
s.events.On(errorEvent(service.Name, err.Error()))
return nil, fmt.Errorf("failed to %s service provider: %s", action, err.Error())
}
switch command {
case "up":
s.events.On(progress.CreatedEvent(service.Name))
s.events.On(createdEvent(service.Name))
case "down":
s.events.On(progress.RemovedEvent(service.Name))
s.events.On(removedEvent(service.Name))
}
return variables, nil
}

176
pkg/compose/progress.go Normal file
View File

@@ -0,0 +1,176 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package compose
import (
"context"
"fmt"
"github.com/docker/compose/v2/pkg/api"
)
type progressFunc func(context.Context) error
func Run(ctx context.Context, pf progressFunc, operation string, bus api.EventProcessor) error {
bus.Start(ctx, operation)
err := pf(ctx)
bus.Done(operation, err != nil)
return err
}
// errorEvent creates a new Error Resource with message
func errorEvent(id string, msg string) api.Resource {
return api.Resource{
ID: id,
Status: api.Error,
Text: api.StatusError,
Details: msg,
}
}
// errorEventf creates a new Error Resource with format message
func errorEventf(id string, msg string, args ...any) api.Resource {
return errorEvent(id, fmt.Sprintf(msg, args...))
}
// creatingEvent creates a new Create in progress Resource
func creatingEvent(id string) api.Resource {
return newEvent(id, api.Working, api.StatusCreating)
}
// startingEvent creates a new Starting in progress Resource
func startingEvent(id string) api.Resource {
return newEvent(id, api.Working, api.StatusStarting)
}
// startedEvent creates a new Started in progress Resource
func startedEvent(id string) api.Resource {
return newEvent(id, api.Done, api.StatusStarted)
}
// waiting creates a new waiting event
func waiting(id string) api.Resource {
return newEvent(id, api.Working, api.StatusWaiting)
}
// healthy creates a new healthy event
func healthy(id string) api.Resource {
return newEvent(id, api.Done, api.StatusHealthy)
}
// exited creates a new exited event
func exited(id string) api.Resource {
return newEvent(id, api.Done, api.StatusExited)
}
// restartingEvent creates a new Restarting in progress Resource
func restartingEvent(id string) api.Resource {
return newEvent(id, api.Working, api.StatusRestarting)
}
// runningEvent creates a new Running in progress Resource
func runningEvent(id string) api.Resource {
return newEvent(id, api.Done, api.StatusRunning)
}
// createdEvent creates a new Created (done) Resource
func createdEvent(id string) api.Resource {
return newEvent(id, api.Done, api.StatusCreated)
}
// stoppingEvent creates a new Stopping in progress Resource
func stoppingEvent(id string) api.Resource {
return newEvent(id, api.Working, api.StatusStopping)
}
// stoppedEvent creates a new Stopping in progress Resource
func stoppedEvent(id string) api.Resource {
return newEvent(id, api.Done, api.StatusStopped)
}
// killingEvent creates a new Killing in progress Resource
func killingEvent(id string) api.Resource {
return newEvent(id, api.Working, api.StatusKilling)
}
// killedEvent creates a new Killed in progress Resource
func killedEvent(id string) api.Resource {
return newEvent(id, api.Done, api.StatusKilled)
}
// removingEvent creates a new Removing in progress Resource
func removingEvent(id string) api.Resource {
return newEvent(id, api.Working, api.StatusRemoving)
}
// removedEvent creates a new removed (done) Resource
func removedEvent(id string) api.Resource {
return newEvent(id, api.Done, api.StatusRemoved)
}
// buildingEvent creates a new Building in progress Resource
func buildingEvent(id string) api.Resource {
return newEvent("Image "+id, api.Working, api.StatusBuilding)
}
// builtEvent creates a new built (done) Resource
func builtEvent(id string) api.Resource {
return newEvent("Image "+id, api.Done, api.StatusBuilt)
}
// pullingEvent creates a new pulling (in progress) Resource
func pullingEvent(id string) api.Resource {
return newEvent("Image "+id, api.Working, api.StatusPulling)
}
// pulledEvent creates a new pulled (done) Resource
func pulledEvent(id string) api.Resource {
return newEvent("Image "+id, api.Done, api.StatusPulled)
}
// skippedEvent creates a new Skipped Resource
func skippedEvent(id string, reason string) api.Resource {
return api.Resource{
ID: id,
Status: api.Warning,
Text: "Skipped: " + reason,
}
}
// newEvent new event
func newEvent(id string, status api.EventStatus, text string, reason ...string) api.Resource {
r := api.Resource{
ID: id,
Status: status,
Text: text,
}
if len(reason) > 0 {
r.Details = reason[0]
}
return r
}
type ignore struct{}
func (q *ignore) Start(_ context.Context, _ string) {
}
func (q *ignore) Done(_ string, _ bool) {
}
func (q *ignore) On(_ ...api.Resource) {
}

View File

@@ -35,7 +35,6 @@ import (
"github.com/docker/compose/v2/internal/oci"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose/transform"
"github.com/docker/compose/v2/pkg/progress"
"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
@@ -43,7 +42,7 @@ import (
)
func (s *composeService) Publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.publish(ctx, project, repository, options)
}, "publish", s.events)
}
@@ -71,10 +70,10 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
return err
}
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: repository,
Text: "publishing",
Status: progress.Working,
Status: api.Working,
})
if logrus.IsLevelEnabled(logrus.DebugLevel) {
logrus.Debug("publishing layers")
@@ -98,10 +97,10 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
if err != nil {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: repository,
Text: "publishing",
Status: progress.Error,
Status: api.Error,
})
return err
}
@@ -150,10 +149,10 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
}
}
}
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: repository,
Text: "published",
Status: progress.Done,
Status: api.Done,
})
return nil
}

View File

@@ -40,11 +40,10 @@ import (
"github.com/docker/compose/v2/internal/registry"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
)
func (s *composeService) Pull(ctx context.Context, project *types.Project, options api.PullOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.pull(ctx, project, options)
}, "pull", s.events)
}
@@ -67,9 +66,9 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
i := 0
for name, service := range project.Services {
if service.Image == "" {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: name,
Status: progress.Done,
Status: api.Done,
Text: "Skipped",
Details: "No image to be pulled",
})
@@ -78,17 +77,17 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
switch service.PullPolicy {
case types.PullPolicyNever, types.PullPolicyBuild:
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: "Image " + service.Image,
Status: progress.Done,
Status: api.Done,
Text: "Skipped",
})
continue
case types.PullPolicyMissing, types.PullPolicyIfNotPresent:
if imageAlreadyPresent(service.Image, images) {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: "Image " + service.Image,
Status: progress.Done,
Status: api.Done,
Text: "Skipped",
Details: "Image is already present locally",
})
@@ -97,9 +96,9 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
}
if service.Build != nil && opts.IgnoreBuildable {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: "Image " + service.Image,
Status: progress.Done,
Status: api.Done,
Text: "Skipped",
Details: "Image can be built",
})
@@ -122,7 +121,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
}
if !opts.IgnoreFailures && service.Build == nil {
if s.dryRun {
s.events.On(progress.ErrorEventf("Image "+service.Image,
s.events.On(errorEventf("Image "+service.Image,
"error pulling image: %s", service.Image))
}
// fail fast if image can't be pulled nor built
@@ -174,7 +173,7 @@ func getUnwrappedErrorMessage(err error) string {
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))
s.events.On(pullingEvent(service.Image))
ref, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return "", err
@@ -196,9 +195,9 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
})
if ctx.Err() != nil {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: resource,
Status: progress.Warning,
Status: api.Warning,
Text: "Interrupted",
})
return "", nil
@@ -207,16 +206,16 @@ 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 {
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: resource,
Status: progress.Warning,
Status: api.Warning,
Text: getUnwrappedErrorMessage(err),
})
return "", err
}
if err != nil {
s.events.On(progress.ErrorEvent(resource, getUnwrappedErrorMessage(err)))
s.events.On(errorEvent(resource, getUnwrappedErrorMessage(err)))
return "", err
}
@@ -236,7 +235,7 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
toPullProgressEvent(resource, jm, s.events)
}
}
s.events.On(progress.PulledEvent(service.Image))
s.events.On(pulledEvent(service.Image))
inspected, err := s.apiClient().ImageInspect(ctx, service.Image)
if err != nil {
@@ -383,7 +382,7 @@ func isServiceImageToBuild(service types.ServiceConfig, services types.Services)
const (
PreparingPhase = "Preparing"
WaitingPhase = "Waiting"
WaitingPhase = "waiting"
PullingFsPhase = "Pulling fs layer"
DownloadingPhase = "Downloading"
DownloadCompletePhase = "Download complete"
@@ -393,7 +392,7 @@ const (
PullCompletePhase = "Pull complete"
)
func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events progress.EventProcessor) {
func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.EventProcessor) {
if jm.ID == "" || jm.Progress == nil {
return
}
@@ -403,7 +402,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events progr
total int64
percent int
current int64
status = progress.Working
status = api.Working
)
text = jm.Progress.String()
@@ -420,22 +419,22 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events progr
}
}
case DownloadCompletePhase, AlreadyExistsPhase, PullCompletePhase:
status = progress.Done
status = api.Done
percent = 100
}
if strings.Contains(jm.Status, "Image is up to date") ||
strings.Contains(jm.Status, "Downloaded newer image") {
status = progress.Done
status = api.Done
percent = 100
}
if jm.Error != nil {
status = progress.Error
status = api.Error
text = jm.Error.Message
}
events.On(progress.Event{
events.On(api.Resource{
ID: jm.ID,
ParentID: parent,
Current: current,

View File

@@ -33,14 +33,13 @@ import (
"github.com/docker/compose/v2/internal/registry"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
)
func (s *composeService) Push(ctx context.Context, project *types.Project, options api.PushOptions) error {
if options.Quiet {
return s.push(ctx, project, options)
}
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.push(ctx, project, options)
}, "push", s.events)
}
@@ -54,9 +53,9 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio
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)
}
s.events.On(progress.Event{
s.events.On(api.Resource{
ID: service.Name,
Status: progress.Done,
Status: api.Done,
Text: "Skipped",
})
continue
@@ -68,16 +67,16 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio
for _, tag := range tags {
eg.Go(func() error {
s.events.On(progress.NewEvent(tag, progress.Working, "Pushing"))
s.events.On(newEvent(tag, api.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()))
s.events.On(newEvent(tag, api.Error, err.Error()))
return err
}
s.events.On(progress.NewEvent(tag, progress.Warning, err.Error()))
s.events.On(newEvent(tag, api.Warning, err.Error()))
} else {
s.events.On(progress.NewEvent(tag, progress.Done, "Pushed"))
s.events.On(newEvent(tag, api.Done, "Pushed"))
}
return nil
})
@@ -129,24 +128,24 @@ func (s *composeService) pushServiceImage(ctx context.Context, tag string, quiet
return nil
}
func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, events progress.EventProcessor) {
func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, events api.EventProcessor) {
if jm.ID == "" {
// skipped
return
}
var (
text string
status = progress.Working
status = api.Working
total int64
current int64
percent int
)
if isDone(jm) {
status = progress.Done
status = api.Done
percent = 100
}
if jm.Error != nil {
status = progress.Error
status = api.Error
text = jm.Error.Message
}
if jm.Progress != nil {
@@ -160,7 +159,7 @@ func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, events progr
}
}
events.On(progress.Event{
events.On(api.Resource{
ParentID: prefix,
ID: jm.ID,
Text: text,

View File

@@ -24,8 +24,6 @@ import (
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/docker/api/types/container"
"golang.org/x/sync/errgroup"
"github.com/docker/compose/v2/pkg/progress"
)
func (s *composeService) Remove(ctx context.Context, projectName string, options api.RemoveOptions) error {
@@ -76,8 +74,7 @@ func (s *composeService) Remove(ctx context.Context, projectName string, options
})
if len(names) == 0 {
_, _ = fmt.Fprintln(s.stdinfo(), "No stopped containers")
return nil
return api.ErrNoResources
}
msg := fmt.Sprintf("Going to remove %s", strings.Join(names, ", "))
@@ -92,7 +89,7 @@ func (s *composeService) Remove(ctx context.Context, projectName string, options
return nil
}
}
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.remove(ctx, stoppedContainers, options)
}, "remove", s.events)
}
@@ -102,13 +99,13 @@ func (s *composeService) remove(ctx context.Context, containers Containers, opti
for _, ctr := range containers {
eg.Go(func() error {
eventName := getContainerProgressName(ctr)
s.events.On(progress.RemovingEvent(eventName))
s.events.On(removingEvent(eventName))
err := s.apiClient().ContainerRemove(ctx, ctr.ID, container.RemoveOptions{
RemoveVolumes: options.Volumes,
Force: options.Force,
})
if err == nil {
s.events.On(progress.RemovedEvent(eventName))
s.events.On(removedEvent(eventName))
}
return err
})

View File

@@ -22,14 +22,13 @@ import (
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/compose/v2/pkg/utils"
"github.com/docker/docker/api/types/container"
"golang.org/x/sync/errgroup"
)
func (s *composeService) Restart(ctx context.Context, projectName string, options api.RestartOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.restart(ctx, strings.ToLower(projectName), options)
}, "restart", s.events)
}
@@ -93,13 +92,13 @@ func (s *composeService) restart(ctx context.Context, projectName string, option
}
}
eventName := getContainerProgressName(ctr)
s.events.On(progress.RestartingEvent(eventName))
s.events.On(restartingEvent(eventName))
timeout := utils.DurationSecondToInt(options.Timeout)
err = s.apiClient().ContainerRestart(ctx, ctr.ID, container.StopOptions{Timeout: timeout})
if err != nil {
return err
}
s.events.On(progress.StartedEvent(eventName))
s.events.On(startedEvent(eventName))
for _, hook := range def.PostStart {
err = s.runHook(ctx, ctr, def, hook, nil)
if err != nil {

View File

@@ -28,7 +28,6 @@ import (
"github.com/docker/cli/cli"
cmd "github.com/docker/cli/cli/command/container"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/docker/docker/pkg/stringid"
)
@@ -65,7 +64,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
return "", err
}
err = progress.Run(ctx, func(ctx context.Context) error {
err = Run(ctx, func(ctx context.Context) error {
return s.startDependencies(ctx, project, opts)
}, "run", s.events)
if err != nil {

View File

@@ -21,11 +21,10 @@ import (
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v2/internal/tracing"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
)
func (s *composeService) Scale(ctx context.Context, project *types.Project, options api.ScaleOptions) error {
return progress.Run(ctx, tracing.SpanWrapFunc("project/scale", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error {
return Run(ctx, tracing.SpanWrapFunc("project/scale", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error {
err := s.create(ctx, project, api.CreateOptions{Services: options.Services})
if err != nil {
return err

View File

@@ -23,7 +23,6 @@ import (
"strings"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
containerType "github.com/docker/docker/api/types/container"
"github.com/compose-spec/compose-go/v2/types"
@@ -31,7 +30,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 Run(ctx, func(ctx context.Context) error {
return s.start(ctx, strings.ToLower(projectName), options, nil)
}, "start", s.events)
}

View File

@@ -22,11 +22,10 @@ import (
"strings"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
)
func (s *composeService) Stop(ctx context.Context, projectName string, options api.StopOptions) error {
return progress.Run(ctx, func(ctx context.Context) error {
return Run(ctx, func(ctx context.Context) error {
return s.stop(ctx, strings.ToLower(projectName), options, nil)
}, "stop", s.events)
}

View File

@@ -33,14 +33,13 @@ import (
"github.com/docker/compose/v2/cmd/formatter"
"github.com/docker/compose/v2/internal/tracing"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
"github.com/eiannone/keyboard"
"github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
)
func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { //nolint:gocyclo
err := progress.Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error {
err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error {
err := s.create(ctx, project, options.Create)
if err != nil {
return err
@@ -126,7 +125,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
first := true
gracefulTeardown := func() {
first = false
fmt.Println("Gracefully Stopping... press Ctrl+C again to force")
s.events.On(newEvent(api.ResourceCompose, api.Working, api.StatusStopping, "Gracefully Stopping... press Ctrl+C again to force"))
eg.Go(func() error {
err = s.stop(context.WithoutCancel(globalCtx), project.Name, api.StopOptions{
Services: options.Create.Services,
@@ -162,7 +161,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
All: true,
})
// Ignore errors indicating that some of the containers were already stopped or removed.
if errdefs.IsNotFound(err) || errdefs.IsConflict(err) {
if errdefs.IsNotFound(err) || errdefs.IsConflict(err) || errors.Is(err, api.ErrNoResources) {
return nil
}
@@ -205,7 +204,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
}
once = false
exitCode = event.ExitCode
_, _ = fmt.Fprintln(s.stdinfo(), progress.ErrorColor("Aborting on container exit..."))
s.events.On(newEvent(api.ResourceCompose, api.Working, api.StatusStopping, "Aborting on container exit..."))
eg.Go(func() error {
err = s.stop(context.WithoutCancel(globalCtx), project.Name, api.StopOptions{
Services: options.Create.Services,

View File

@@ -33,9 +33,9 @@ import (
"github.com/docker/compose/v2/internal/sync"
"github.com/docker/compose/v2/internal/tracing"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/progress"
cutils "github.com/docker/compose/v2/pkg/utils"
"github.com/docker/compose/v2/pkg/watch"
"github.com/moby/buildkit/util/progress/progressui"
"github.com/compose-spec/compose-go/v2/types"
"github.com/compose-spec/compose-go/v2/utils"
@@ -472,7 +472,7 @@ func (t tarDockerClient) Exec(ctx context.Context, containerID string, cmd []str
})
}
eg.Go(func() error {
_, err := io.Copy(t.s.stdinfo(), conn.Reader)
_, err := io.Copy(t.s.stdout(), conn.Reader)
return err
})
@@ -613,7 +613,7 @@ func (s *composeService) rebuild(ctx context.Context, project *types.Project, se
options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service(s) %q after changes were detected...", services))
// restrict the build to ONLY this service, not any of its dependencies
options.Build.Services = services
options.Build.Progress = progress.ModePlain
options.Build.Progress = string(progressui.PlainMode)
options.Build.Out = cutils.GetWriter(func(line string) {
options.LogTo.Log(api.WatchLogger, line)
})

View File

@@ -1,47 +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 (
"github.com/morikuni/aec"
)
type colorFunc func(string) string
var (
nocolor colorFunc = func(s string) string {
return s
}
DoneColor colorFunc = aec.BlueF.Apply
TimerColor colorFunc = aec.BlueF.Apply
CountColor colorFunc = aec.YellowF.Apply
WarningColor colorFunc = aec.YellowF.With(aec.Bold).Apply
SuccessColor colorFunc = aec.GreenF.Apply
ErrorColor colorFunc = aec.RedF.With(aec.Bold).Apply
PrefixColor colorFunc = aec.CyanF.Apply
)
func NoColor() {
DoneColor = nocolor
TimerColor = nocolor
CountColor = nocolor
WarningColor = nocolor
SuccessColor = nocolor
ErrorColor = nocolor
PrefixColor = nocolor
}

View File

@@ -1,234 +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"
)
// EventStatus indicates the status of an action
type EventStatus int
const (
// Working means that the current task is working
Working EventStatus = iota
// Done means that the current task is done
Done
// Warning means that the current task has warning
Warning
// Error means that the current task has errored
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"
StatusCommitting = "Committing"
StatusCommitted = "Committed"
StatusCopying = "Copying"
StatusCopied = "Copied"
StatusExporting = "Exporting"
StatusExported = "Exported"
)
// Event represents a progress event.
type Event struct {
ID string
ParentID string
Text string
Details string
Status EventStatus
Current int64
Percent int
Total int64
}
func (e *Event) StatusText() string {
switch e.Status {
case Working:
return "Working"
case Warning:
return "Warning"
case Done:
return "Done"
default:
return "Error"
}
}
// ErrorEvent creates a new Error Event with message
func ErrorEvent(id string, msg string) Event {
return Event{
ID: id,
Status: Error,
Text: StatusError,
Details: msg,
}
}
// ErrorEventf creates a new Error Event with format message
func ErrorEventf(id string, msg string, args ...any) Event {
return ErrorEvent(id, fmt.Sprintf(msg, args...))
}
// CreatingEvent creates a new Create in progress Event
func CreatingEvent(id string) Event {
return NewEvent(id, Working, StatusCreating)
}
// StartingEvent creates a new Starting in progress Event
func StartingEvent(id string) Event {
return NewEvent(id, Working, StatusStarting)
}
// StartedEvent creates a new Started in progress Event
func StartedEvent(id string) Event {
return NewEvent(id, Done, StatusStarted)
}
// Waiting creates a new waiting event
func Waiting(id string) Event {
return NewEvent(id, Working, StatusWaiting)
}
// Healthy creates a new healthy event
func Healthy(id string) Event {
return NewEvent(id, Done, StatusHealthy)
}
// Exited creates a new exited event
func Exited(id string) Event {
return NewEvent(id, Done, StatusExited)
}
// RestartingEvent creates a new Restarting in progress Event
func RestartingEvent(id string) Event {
return NewEvent(id, Working, StatusRestarting)
}
// RestartedEvent creates a new Restarted in progress Event
func RestartedEvent(id string) Event {
return NewEvent(id, Done, StatusRestarted)
}
// RunningEvent creates a new Running in progress Event
func RunningEvent(id string) Event {
return NewEvent(id, Done, StatusRunning)
}
// CreatedEvent creates a new Created (done) Event
func CreatedEvent(id string) Event {
return NewEvent(id, Done, StatusCreated)
}
// StoppingEvent creates a new Stopping in progress Event
func StoppingEvent(id string) Event {
return NewEvent(id, Working, StatusStopping)
}
// StoppedEvent creates a new Stopping in progress Event
func StoppedEvent(id string) Event {
return NewEvent(id, Done, StatusStopped)
}
// KillingEvent creates a new Killing in progress Event
func KillingEvent(id string) Event {
return NewEvent(id, Working, StatusKilling)
}
// KilledEvent creates a new Killed in progress Event
func KilledEvent(id string) Event {
return NewEvent(id, Done, StatusKilled)
}
// RemovingEvent creates a new Removing in progress Event
func RemovingEvent(id string) Event {
return NewEvent(id, Working, StatusRemoving)
}
// RemovedEvent creates a new removed (done) Event
func RemovedEvent(id string) Event {
return NewEvent(id, Done, StatusRemoved)
}
// BuildingEvent creates a new Building in progress Event
func BuildingEvent(id string) Event {
return NewEvent("Image "+id, Working, StatusBuilding)
}
// BuiltEvent creates a new built (done) Event
func BuiltEvent(id string) Event {
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
func SkippedEvent(id string, reason string) Event {
return Event{
ID: id,
Status: Warning,
Text: "Skipped: " + reason,
}
}
// NewEvent new event
func NewEvent(id string, status EventStatus, text string) Event {
return Event{
ID: id,
Status: status,
Text: text,
}
}
// 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)
}

View File

@@ -1,79 +0,0 @@
/*
Copyright 2024 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"
"encoding/json"
"fmt"
"io"
)
func NewJSONWriter(out io.Writer) EventProcessor {
return &jsonWriter{
out: out,
}
}
type jsonWriter struct {
out io.Writer
dryRun bool
}
type jsonMessage struct {
DryRun bool `json:"dry-run,omitempty"`
Tail bool `json:"tail,omitempty"`
ID string `json:"id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Status string `json:"status,omitempty"`
Text string `json:"text,omitempty"`
Details string `json:"details,omitempty"`
Current int64 `json:"current,omitempty"`
Total int64 `json:"total,omitempty"`
Percent int `json:"percent,omitempty"`
}
func (p *jsonWriter) Start(ctx context.Context, operation string) {
}
func (p *jsonWriter) Event(e Event) {
message := &jsonMessage{
DryRun: p.dryRun,
Tail: false,
ID: e.ID,
Status: e.StatusText(),
Text: e.Text,
Details: e.Details,
ParentID: e.ParentID,
Current: e.Current,
Total: e.Total,
Percent: e.Percent,
}
marshal, err := json.Marshal(message)
if err == nil {
_, _ = fmt.Fprintln(p.out, string(marshal))
}
}
func (p *jsonWriter) On(events ...Event) {
for _, e := range events {
p.Event(e)
}
}
func (p *jsonWriter) Done(_ string, _ bool) {
}

View File

@@ -1,60 +0,0 @@
/*
Copyright 2024 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 (
"bytes"
"encoding/json"
"testing"
"gotest.tools/v3/assert"
)
func TestJsonWriter_Event(t *testing.T) {
var out bytes.Buffer
w := &jsonWriter{
out: &out,
dryRun: true,
}
event := Event{
ID: "service1",
ParentID: "project",
Status: Working,
Text: StatusCreating,
Current: 50,
Total: 100,
Percent: 50,
}
w.Event(event)
var actual jsonMessage
err := json.Unmarshal(out.Bytes(), &actual)
assert.NilError(t, err)
expected := jsonMessage{
DryRun: true,
ID: event.ID,
ParentID: event.ParentID,
Text: StatusCreating,
Status: "Working",
Current: event.Current,
Total: event.Total,
Percent: event.Percent,
}
assert.DeepEqual(t, expected, actual)
}

View File

@@ -1,56 +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"
"github.com/docker/compose/v2/pkg/api"
)
func NewPlainWriter(out io.Writer) EventProcessor {
return &plainWriter{
out: out,
}
}
type plainWriter struct {
out io.Writer
dryRun bool
}
func (p *plainWriter) Start(ctx context.Context, operation string) {
}
func (p *plainWriter) Event(e Event) {
prefix := ""
if p.dryRun {
prefix = api.DRYRUN_PREFIX
}
_, _ = fmt.Fprintln(p.out, prefix, e.ID, e.Text, e.Details)
}
func (p *plainWriter) On(events ...Event) {
for _, e := range events {
p.Event(e)
}
}
func (p *plainWriter) Done(_ string, _ bool) {
}

View File

@@ -1,46 +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 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

View File

@@ -1,34 +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"
func NewQuietWriter() EventProcessor {
return &quiet{}
}
type quiet struct{}
func (q *quiet) Start(_ context.Context, _ string) {
}
func (q *quiet) Done(_ string, _ bool) {
}
func (q *quiet) On(_ ...Event) {
}

View File

@@ -1,70 +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 (
"runtime"
"time"
)
type Spinner struct {
time time.Time
index int
chars []string
stop bool
done string
}
func NewSpinner() *Spinner {
chars := []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
}
done := "⠿"
if runtime.GOOS == "windows" {
chars = []string{"-"}
done = "-"
}
return &Spinner{
index: 0,
time: time.Now(),
chars: chars,
done: done,
}
}
func (s *Spinner) String() string {
if s.stop {
return s.done
}
d := time.Since(s.time)
if d.Milliseconds() > 100 {
s.index = (s.index + 1) % len(s.chars)
}
return s.chars[s.index]
}
func (s *Spinner) Stop() {
s.stop = true
}
func (s *Spinner) Restart() {
s.stop = false
}

View File

@@ -1,429 +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"
"strings"
"sync"
"time"
"github.com/docker/compose/v2/pkg/api"
"github.com/buger/goterm"
"github.com/docker/go-units"
"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
ids []string // tasks ids ordered as first event appeared
tasks map[string]task
repeated bool
numLines int
done chan bool
mtx *sync.Mutex
dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
skipChildEvents bool
operation string
ticker *time.Ticker
suspended bool
}
type task struct {
ID string
parentID string
startTime time.Time
endTime time.Time
text string
details string
status EventStatus
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) Done(operation string, success bool) {
w.done <- true
}
func (w *ttyWriter) On(events ...Event) {
w.mtx.Lock()
defer w.mtx.Unlock()
for _, e := range events {
if w.operation != "start" && (e.Text == StatusStarted || e.Text == StatusStarting) {
// skip those events to avoid mix with container logs
continue
}
w.event(e)
}
}
func (w *ttyWriter) event(e Event) {
// Suspend print while a build is in progress, to avoid collision with buildkit Display
if e.Text == StatusBuilding {
w.ticker.Stop()
w.suspended = true
} else if w.suspended {
w.ticker.Reset(100 * time.Millisecond)
w.suspended = false
}
if last, ok := w.tasks[e.ID]; ok {
switch e.Status {
case Done, Error, Warning:
if last.status != e.Status {
last.stop()
}
case Working:
last.hasMore()
}
last.status = e.Status
last.text = e.Text
last.details = e.Details
// progress can only go up
if e.Total > last.total {
last.total = e.Total
}
if e.Current > last.current {
last.current = e.Current
}
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
}
w.tasks[e.ID] = last
} else {
t := task{
ID: e.ID,
parentID: e.ParentID,
startTime: time.Now(),
text: e.Text,
details: e.Details,
status: e.Status,
current: e.Current,
percent: e.Percent,
total: e.Total,
spinner: NewSpinner(),
}
if e.Status == Done || e.Status == Error {
t.stop()
}
w.tasks[e.ID] = t
w.ids = append(w.ids, e.ID)
}
w.printEvent(e)
}
func (w *ttyWriter) printEvent(e Event) {
if w.operation != "" {
// event will be displayed by progress UI on ticker's ticks
return
}
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, color(e.Text), e.Details)
}
func (w *ttyWriter) print() {
w.mtx.Lock()
defer w.mtx.Unlock()
if len(w.tasks) == 0 {
return
}
terminalWidth := goterm.Width()
b := aec.EmptyBuilder
for i := 0; i <= w.numLines; i++ {
b = b.Up(1)
}
if !w.repeated {
b = b.Down(1)
}
w.repeated = true
_, _ = fmt.Fprint(w.out, b.Column(0).ANSI)
// Hide the cursor while we are printing
_, _ = fmt.Fprint(w.out, aec.Hide)
defer func() {
_, _ = fmt.Fprint(w.out, aec.Show)
}()
firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
_, _ = fmt.Fprintln(w.out, firstLine)
var statusPadding int
for _, t := range w.tasks {
l := len(t.ID)
if statusPadding < l {
statusPadding = l
}
if t.parentID != "" {
statusPadding -= 2
}
}
if len(w.tasks) > goterm.Height()-2 {
w.skipChildEvents = true
}
numLines := 0
for _, id := range w.ids { // iterate on ids to enforce a consistent order
t := w.tasks[id]
if t.parentID != "" {
continue
}
line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
_, _ = fmt.Fprint(w.out, line)
numLines++
for _, t := range w.tasks {
if t.parentID == t.ID {
if w.skipChildEvents {
continue
}
line := w.lineText(t, " ", terminalWidth, statusPadding, w.dryRun)
_, _ = fmt.Fprint(w.out, line)
numLines++
}
}
}
for i := numLines; i < w.numLines; i++ {
if numLines < goterm.Height()-2 {
_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
numLines++
}
}
w.numLines = numLines
}
func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
endTime := time.Now()
if t.status != Working {
endTime = t.startTime
if (t.endTime != time.Time{}) {
endTime = t.endTime
}
}
prefix := ""
if dryRun {
prefix = PrefixColor(api.DRYRUN_PREFIX)
}
elapsed := endTime.Sub(t.startTime).Seconds()
var (
hideDetails bool
total int64
current int64
completion []string
)
// only show the aggregated progress while the root operation is in-progress
if parent := t; parent.status == Working {
for _, id := range w.ids {
child := w.tasks[id]
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])
}
}
}
// don't try to show detailed progress if we don't have any idea
if total == 0 {
hideDetails = true
}
txt := t.ID
if len(completion) > 0 {
var progress string
if !hideDetails {
progress = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
}
txt = fmt.Sprintf("%s [%s]%s",
t.ID,
SuccessColor(strings.Join(completion, "")),
progress,
)
}
textLen := len(txt)
padding := statusPadding - textLen
if padding < 0 {
padding = 0
}
// calculate the max length for the status text, on errors it
// is 2-3 lines long and breaks the line formatting
maxDetailsLen := terminalWidth - textLen - statusPadding - 15
details := t.details
// 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 maxDetailsLen > 0 && len(details) > maxDetailsLen {
details = details[:maxDetailsLen] + "..."
}
text := fmt.Sprintf("%s %s%s %s %s%s %s",
pad,
spinner(t),
prefix,
txt,
strings.Repeat(" ", padding),
colorFn(t.status)(t.text),
details,
)
timer := fmt.Sprintf("%.1fs ", elapsed)
o := align(text, TimerColor(timer), terminalWidth)
return o
}
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 _, t := range tasks {
if t.status != Working {
i++
}
}
return i
}
func align(l, r string, w int) string {
ll := lenAnsi(l)
lr := lenAnsi(r)
pad := ""
count := w - ll - lr
if count > 0 {
pad = strings.Repeat(" ", count)
}
return fmt.Sprintf("%s%s%s\n", l, pad, r)
}
// lenAnsi count of user-perceived characters in ANSI string.
func lenAnsi(s string) int {
length := 0
ansiCode := false
for _, r := range s {
if r == '\x1b' {
ansiCode = true
continue
}
if ansiCode && r == 'm' {
ansiCode = false
continue
}
if !ansiCode {
length++
}
}
return length
}
var percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")