From 7e79df11f7e3aec85ef65efc801152f5ddfae23c Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 11:33:54 -0800 Subject: [PATCH 01/40] refactor printing existing deployments --- src/cmd/cli/command/compose.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index e77be3db5..7f2213d87 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -269,7 +269,7 @@ func confirmDeployment(targetDirectory string, existingDeployments []*defangv1.D } func printExistingDeployments(existingDeployments []*defangv1.Deployment) { - term.Info("This project was previously deployed to the following locations:") + term.Info("This project has already deployed to the following locations:") deploymentStrings := make([]string, 0, len(existingDeployments)) for _, dep := range existingDeployments { var providerId client.ProviderID From 4301b65cad399e40fb77c0f5217ca08486247244 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 13:51:41 -0800 Subject: [PATCH 02/40] factor out WatchServiceState --- src/pkg/cli/client/errors.go | 7 ++++++- src/pkg/cli/subscribe.go | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/pkg/cli/client/errors.go b/src/pkg/cli/client/errors.go index 49c831a3b..c9a3b0a75 100644 --- a/src/pkg/cli/client/errors.go +++ b/src/pkg/cli/client/errors.go @@ -1,6 +1,11 @@ package client -import "fmt" +import ( + "errors" + "fmt" +) + +var ErrDeploymentSucceeded = errors.New("deployment succeeded") type ErrDeploymentFailed struct { Message string diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index 9b05106c7..ad8eaa63c 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -14,16 +14,14 @@ var ErrNothingToMonitor = errors.New("no services to monitor") type ServiceStates = map[string]defangv1.ServiceState -func WaitServiceState( +func WatchServiceState( ctx context.Context, provider client.Provider, - targetState defangv1.ServiceState, projectName string, etag types.ETag, services []string, + cb func(*defangv1.SubscribeResponse, *ServiceStates) error, ) (ServiceStates, error) { - term.Debugf("waiting for services %v to reach state %s\n", services, targetState) // TODO: don't print in Go-routine - if len(services) == 0 { return nil, ErrNothingToMonitor } @@ -79,17 +77,39 @@ func WaitServiceState( } serviceStates[msg.Name] = msg.State + err := cb(msg, &serviceStates) + if err != nil { + if errors.Is(err, client.ErrDeploymentSucceeded) { + return serviceStates, nil + } + return serviceStates, err + } + } +} + +func WaitServiceState( + ctx context.Context, + provider client.Provider, + targetState defangv1.ServiceState, + projectName string, + etag types.ETag, + services []string, +) (ServiceStates, error) { + term.Debugf("waiting for services %v to reach state %s\n", services, targetState) // TODO: don't print in Go-routine + return WatchServiceState(ctx, provider, projectName, etag, services, func(msg *defangv1.SubscribeResponse, serviceStates *ServiceStates) error { // exit early on detecting a FAILED state switch msg.State { case defangv1.ServiceState_BUILD_FAILED, defangv1.ServiceState_DEPLOYMENT_FAILED: - return serviceStates, client.ErrDeploymentFailed{Service: msg.Name, Message: msg.Status} + return client.ErrDeploymentFailed{Service: msg.Name, Message: msg.Status} } - if allInState(targetState, serviceStates) { - return serviceStates, nil // all services are in the target state + if allInState(targetState, *serviceStates) { + return client.ErrDeploymentSucceeded // signal successful completion } - } + + return nil + }) } func allInState(targetState defangv1.ServiceState, serviceStates ServiceStates) bool { From a1a6e4289a37f42648237240ef741f315e41baca Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 13:52:19 -0800 Subject: [PATCH 03/40] factor out Monitor from TailAndMonitor --- src/pkg/cli/tailAndMonitor.go | 65 +++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index d21e32932..151456ad7 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -15,12 +15,9 @@ import ( "github.com/bufbuild/connect-go" ) -const targetServiceState = defangv1.ServiceState_DEPLOYMENT_COMPLETED - -func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { - tailOptions.Follow = true - if tailOptions.Deployment == "" { - panic("tailOptions.Deployment must be a valid deployment ID") +func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (ServiceStates, error) { + if deploymentID == "" { + panic("deploymentID must be a valid deployment ID") } if waitTimeout > 0 { var cancelTimeout context.CancelFunc @@ -28,42 +25,66 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie defer cancelTimeout() } - tailCtx, cancelTail := context.WithCancelCause(context.Background()) - defer cancelTail(nil) // to cancel tail and clean-up context - svcStatusCtx, cancelSvcStatus := context.WithCancelCause(ctx) - defer cancelSvcStatus(nil) // to cancel WaitServiceState and clean-up context + defer cancelSvcStatus(nil) _, computeServices := splitManagedAndUnmanagedServices(project.Services) - var serviceStates ServiceStates - var cdErr, svcErr error + for _, svc := range computeServices { + term.Infof("%s [%s] %s\n", time.Now().Format(time.TimeOnly), svc, "PENDING") + } + var ( + serviceStates ServiceStates + cdErr, svcErr error + ) wg := &sync.WaitGroup{} wg.Add(2) go func() { defer wg.Done() - // block on waiting for services to reach target state - serviceStates, svcErr = WaitServiceState(svcStatusCtx, provider, targetServiceState, project.Name, tailOptions.Deployment, computeServices) + serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { + // Print service status updates as they arrive + for name, state := range *states { + term.Infof("%s [%s] %s\n", time.Now().Format(time.TimeOnly), name, state.String()) + } + return nil + }) }() go func() { defer wg.Done() - // block on waiting for cdTask to complete if err := WaitForCdTaskExit(ctx, provider); err != nil { cdErr = err - // When CD fails, stop WaitServiceState cancelSvcStatus(cdErr) } }() + wg.Wait() + pkg.SleepWithContext(ctx, 2*time.Second) + + return serviceStates, errors.Join(cdErr, svcErr) +} + +func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { + tailOptions.Follow = true + if tailOptions.Deployment == "" { + panic("tailOptions.Deployment must be a valid deployment ID") + } + + tailCtx, cancelTail := context.WithCancelCause(context.Background()) + defer cancelTail(nil) // to cancel tail and clean-up context + errMonitoringDone := errors.New("monitoring done") // pseudo error to signal that monitoring is done + var serviceStates ServiceStates + var monitorErr error + + // Run Monitor in a goroutine go func() { - wg.Wait() + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, tailOptions.Deployment) pkg.SleepWithContext(ctx, 2*time.Second) // a delay before cancelling tail to make sure we get last status messages - cancelTail(errMonitoringDone) // cancel the tail when both goroutines are done + cancelTail(errMonitoringDone) // cancel the tail when monitoring is done }() tailOptions.PrintBookends = false @@ -82,13 +103,13 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie switch { case errors.Is(err, io.EOF): - break // an end condition was detected; cdErr and/or svcErr might be nil + break // an end condition was detected; monitorErr might be nil case errors.Is(context.Cause(ctx), context.Canceled): term.Warn("Deployment is not finished. Service(s) might not be running.") case errors.Is(context.Cause(tailCtx), errMonitoringDone): - break // the monitoring stopped the tail; cdErr and/or svcErr will have been set + break // the monitoring stopped the tail; monitorErr will have been set case errors.Is(context.Cause(ctx), context.DeadlineExceeded): // Tail was canceled when wait-timeout is reached; show a warning and exit with an error @@ -96,11 +117,11 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie fallthrough default: - tailErr = err // report the error, in addition to the cdErr and svcErr + tailErr = err // report the error, in addition to the monitorErr } } - return serviceStates, errors.Join(cdErr, svcErr, tailErr) + return serviceStates, errors.Join(monitorErr, tailErr) } func CanMonitorService(service *compose.ServiceConfig) bool { From 3ec871d27365025eb25d247189303dfeca58cac8 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 19 Dec 2025 14:41:51 -0800 Subject: [PATCH 04/40] fix Monitor function not returning when cd task exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WatchServiceState function was blocking on serverStream.Receive() even when the context was cancelled by cancelSvcStatus. This change moves the stream receiving to a separate goroutine and uses channels with a select statement to properly handle context cancellation. đŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/cmd/cli/command/compose.go | 2 +- src/pkg/cli/subscribe.go | 76 ++++++++++++++++++++++------------ src/pkg/cli/tailAndMonitor.go | 2 +- 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 7f2213d87..b2c635ff9 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -187,7 +187,7 @@ func makeComposeUpCmd() *cobra.Command { tailOptions := newTailOptionsForDeploy(deploy.Etag, since, global.Verbose) serviceStates, err := cli.TailAndMonitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, tailOptions) - if err != nil { + if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err debugger, err := debug.NewDebugger(ctx, global.Cluster, &global.Stack) if err != nil { diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index ad8eaa63c..eea111b66 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -48,41 +48,65 @@ func WatchServiceState( } // Monitor for when all services are completed to end this command - for { - if !serverStream.Receive() { - // Reconnect on Error: internal: stream error: stream ID 5; INTERNAL_ERROR; received from peer - if isTransientError(serverStream.Err()) { - if err := provider.DelayBeforeRetry(ctx); err != nil { - return serviceStates, err - } - serverStream, err = provider.Subscribe(ctx, &subscribeRequest) - if err != nil { - return serviceStates, err + msgChan := make(chan *defangv1.SubscribeResponse, 1) + errChan := make(chan error, 1) + + // Run stream receiving in a separate goroutine + go func() { + for { + if !serverStream.Receive() { + // Reconnect on Error: internal: stream error: stream ID 5; INTERNAL_ERROR; received from peer + if isTransientError(serverStream.Err()) { + if err := provider.DelayBeforeRetry(ctx); err != nil { + errChan <- err + return + } + serverStream, err = provider.Subscribe(ctx, &subscribeRequest) + if err != nil { + errChan <- err + return + } + continue } + errChan <- serverStream.Err() + return + } + + msg := serverStream.Msg() + if msg == nil { continue } - return serviceStates, serverStream.Err() - } - msg := serverStream.Msg() - if msg == nil { - continue + select { + case msgChan <- msg: + case <-ctx.Done(): + return + } } + }() - term.Debugf("service %s with state ( %s ) and status: %s\n", msg.Name, msg.State, msg.Status) // TODO: don't print in Go-routine + for { + select { + case <-ctx.Done(): + return serviceStates, ctx.Err() + case err := <-errChan: + return serviceStates, err + case msg := <-msgChan: + term.Debugf("service %s with state ( %s ) and status: %s\n", msg.Name, msg.State, msg.Status) // TODO: don't print in Go-routine - if _, ok := serviceStates[msg.Name]; !ok { - term.Debugf("unexpected service %s update", msg.Name) // TODO: don't print in Go-routine - continue - } + if _, ok := serviceStates[msg.Name]; !ok { + term.Debugf("unexpected service %s update", msg.Name) // TODO: don't print in Go-routine + continue + } - serviceStates[msg.Name] = msg.State - err := cb(msg, &serviceStates) - if err != nil { - if errors.Is(err, client.ErrDeploymentSucceeded) { - return serviceStates, nil + serviceStates[msg.Name] = msg.State + err := cb(msg, &serviceStates) + if err != nil { + if errors.Is(err, client.ErrDeploymentSucceeded) { + return serviceStates, nil + } + return serviceStates, err } - return serviceStates, err } } } diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index 151456ad7..593640c08 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -56,8 +56,8 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov defer wg.Done() if err := WaitForCdTaskExit(ctx, provider); err != nil { cdErr = err - cancelSvcStatus(cdErr) } + cancelSvcStatus(cdErr) }() wg.Wait() From 9aa504f2867e816f3c83072173994930f06a2b2f Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 13:52:41 -0800 Subject: [PATCH 05/40] call Monitor after ComposeUp --- src/cmd/cli/command/compose.go | 4 ++-- src/pkg/cli/tailAndMonitor.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index b2c635ff9..9824c1057 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -185,8 +185,8 @@ func makeComposeUpCmd() *cobra.Command { } term.Info("Tailing logs for", tailSource, "; press Ctrl+C to detach:") - tailOptions := newTailOptionsForDeploy(deploy.Etag, since, global.Verbose) - serviceStates, err := cli.TailAndMonitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, tailOptions) + term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") + serviceStates, err := cli.Monitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, deploy.Etag) if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err debugger, err := debug.NewDebugger(ctx, global.Cluster, &global.Stack) diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index 593640c08..c61ddef3e 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -31,7 +31,7 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov _, computeServices := splitManagedAndUnmanagedServices(project.Services) for _, svc := range computeServices { - term.Infof("%s [%s] %s\n", time.Now().Format(time.TimeOnly), svc, "PENDING") + term.Infof("[%s] %s\n", svc, "DEPLOYMENT_PENDING") } var ( @@ -46,7 +46,7 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { // Print service status updates as they arrive for name, state := range *states { - term.Infof("%s [%s] %s\n", time.Now().Format(time.TimeOnly), name, state.String()) + term.Infof("[%s] %s\n", name, state.String()) } return nil }) From 7f699ec42cb2de68c36432583ddd36cec1f3b55d Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 14:31:35 -0800 Subject: [PATCH 06/40] print logs on deployment failures --- src/cmd/cli/command/compose.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 9824c1057..374152982 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -189,6 +189,22 @@ func makeComposeUpCmd() *cobra.Command { serviceStates, err := cli.Monitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, deploy.Etag) if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err + // TODO: only show the most relevant logs: + // * avoid showing service logs if the failure was during the build stage + // * avoid showing build logs for services that built successfully + // * only show cd logs if the failure was during deployment + err := cli.Tail(ctx, provider, project.Name, cli.TailOptions{ + Deployment: deploy.Etag, + LogType: logs.LogTypeAll, + Since: since, + Verbose: true, + Follow: false, + }) + if err != nil && !errors.Is(err, io.EOF) { + term.Warn("Failed to tail logs for deployment error", err) + return deploymentErr + } + debugger, err := debug.NewDebugger(ctx, global.Cluster, &global.Stack) if err != nil { term.Warn("Failed to initialize debugger:", err) From 1e33d89a36c87909bd222a7e19e1831845e89d36 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 19 Dec 2025 12:28:55 -0800 Subject: [PATCH 07/40] print targeted build logs after failure --- src/cmd/cli/command/compose.go | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 374152982..55c303ffa 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -189,17 +189,28 @@ func makeComposeUpCmd() *cobra.Command { serviceStates, err := cli.Monitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, deploy.Etag) if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err - // TODO: only show the most relevant logs: - // * avoid showing service logs if the failure was during the build stage - // * avoid showing build logs for services that built successfully - // * only show cd logs if the failure was during deployment - err := cli.Tail(ctx, provider, project.Name, cli.TailOptions{ + + options := cli.TailOptions{ Deployment: deploy.Etag, LogType: logs.LogTypeAll, Since: since, Verbose: true, Follow: false, - }) + } + + // if any services failed to build, only show build logs for those + // services + var unbuiltServices = make([]string, 0, len(project.Services)) + for service, state := range serviceStates { + if state <= defangv1.ServiceState_BUILD_STOPPING { + unbuiltServices = append(unbuiltServices, service) + } + } + if len(unbuiltServices) > 0 { + options.LogType = logs.LogTypeBuild + options.Services = unbuiltServices + } + err := cli.Tail(ctx, provider, project.Name, options) if err != nil && !errors.Is(err, io.EOF) { term.Warn("Failed to tail logs for deployment error", err) return deploymentErr From 23e5be6907932aa0786288e86f6df67dda65f759 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 19 Dec 2025 12:45:18 -0800 Subject: [PATCH 08/40] preserve the ability to tail live --- src/cmd/cli/command/compose.go | 43 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 55c303ffa..16c2943a4 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -60,6 +60,7 @@ func makeComposeUpCmd() *cobra.Command { var detach, _ = cmd.Flags().GetBool("detach") var utc, _ = cmd.Flags().GetBool("utc") var waitTimeout, _ = cmd.Flags().GetInt("wait-timeout") + var tail, _ = cmd.Flags().GetBool("tail") if utc { cli.EnableUTCMode() @@ -177,27 +178,28 @@ func makeComposeUpCmd() *cobra.Command { term.Info("Detached.") return nil } - - // show users the current streaming logs - tailSource := "all services" - if deploy.Etag != "" { - tailSource = "deployment ID " + deploy.Etag + tailOptions := cli.TailOptions{ + Deployment: deploy.Etag, + LogType: logs.LogTypeAll, + Since: since, + Verbose: true, } - term.Info("Tailing logs for", tailSource, "; press Ctrl+C to detach:") - term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") - serviceStates, err := cli.Monitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, deploy.Etag) + waitTimeoutDuration := time.Duration(waitTimeout) * time.Second + var serviceStates map[string]defangv1.ServiceState + if tail { + tailOptions.Follow = true + serviceStates, err = cli.TailAndMonitor(ctx, project, session.Provider, waitTimeoutDuration, tailOptions) + if err != nil { + return err + } + } else { + term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") + serviceStates, err = cli.Monitor(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) + } if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err - options := cli.TailOptions{ - Deployment: deploy.Etag, - LogType: logs.LogTypeAll, - Since: since, - Verbose: true, - Follow: false, - } - // if any services failed to build, only show build logs for those // services var unbuiltServices = make([]string, 0, len(project.Services)) @@ -207,10 +209,10 @@ func makeComposeUpCmd() *cobra.Command { } } if len(unbuiltServices) > 0 { - options.LogType = logs.LogTypeBuild - options.Services = unbuiltServices + tailOptions.LogType = logs.LogTypeBuild + tailOptions.Services = unbuiltServices } - err := cli.Tail(ctx, provider, project.Name, options) + err := cli.Tail(ctx, provider, project.Name, tailOptions) if err != nil && !errors.Is(err, io.EOF) { term.Warn("Failed to tail logs for deployment error", err) return deploymentErr @@ -250,8 +252,7 @@ func makeComposeUpCmd() *cobra.Command { composeUpCmd.Flags().BoolP("detach", "d", false, "run in detached mode") composeUpCmd.Flags().Bool("force", false, "force a build of the image even if nothing has changed") composeUpCmd.Flags().Bool("utc", false, "show logs in UTC timezone (ie. TZ=UTC)") - composeUpCmd.Flags().Bool("tail", false, "tail the service logs after updating") // obsolete, but keep for backwards compatibility - _ = composeUpCmd.Flags().MarkHidden("tail") + composeUpCmd.Flags().Bool("tail", false, "tail the logs while deploying") composeUpCmd.Flags().VarP(&global.Stack.Mode, "mode", "m", fmt.Sprintf("deployment mode; one of %v", modes.AllDeploymentModes())) composeUpCmd.Flags().Bool("build", true, "build the image before starting the service") // docker-compose compatibility composeUpCmd.Flags().Bool("wait", true, "wait for services to be running|healthy") // docker-compose compatibility From 379d1f48c232e7d6d81e9bae94b66bee0af56af2 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 19 Dec 2025 12:45:46 -0800 Subject: [PATCH 09/40] start with DEPLOYMENT_QUEUED --- src/pkg/cli/tailAndMonitor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index c61ddef3e..da92273d5 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -31,7 +31,7 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov _, computeServices := splitManagedAndUnmanagedServices(project.Services) for _, svc := range computeServices { - term.Infof("[%s] %s\n", svc, "DEPLOYMENT_PENDING") + term.Infof("[%s] %s\n", svc, "DEPLOYMENT_QUEUED") } var ( From b70bcdcb7edfd5d4eb371c00d98c862c930e415e Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 19 Dec 2025 12:58:13 -0800 Subject: [PATCH 10/40] print live service status w/ bubbletea --- src/cmd/cli/command/compose.go | 182 ++++++++++++++++++++++++++++++++- src/go.mod | 20 +++- src/go.sum | 43 ++++++-- src/pkg/cli/tailAndMonitor.go | 19 ++-- 4 files changed, 238 insertions(+), 26 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 16c2943a4..1160e458e 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "slices" + "sort" "strings" + "sync" "time" "github.com/AlecAivazis/survey/v2" @@ -27,12 +29,190 @@ import ( "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) const DEFANG_PORTAL_HOST = "portal.defang.io" const SERVICE_PORTAL_URL = "https://" + DEFANG_PORTAL_HOST + "/service" +type deploymentModel struct { + services map[string]*serviceState + quitting bool + updateCh chan serviceUpdate +} + +type serviceState struct { + status string + spinner spinner.Model +} + +type serviceUpdate struct { + name string + status string +} + +var ( + spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("206")) + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) +) + +func newDeploymentModel(serviceNames []string) *deploymentModel { + services := make(map[string]*serviceState) + + for _, name := range serviceNames { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = spinnerStyle + + services[name] = &serviceState{ + status: "DEPLOYMENT_QUEUED", + spinner: s, + } + } + + return &deploymentModel{ + services: services, + updateCh: make(chan serviceUpdate, 100), + } +} + +func (m *deploymentModel) Init() tea.Cmd { + var cmds []tea.Cmd + for _, svc := range m.services { + cmds = append(cmds, svc.spinner.Tick) + } + return tea.Batch(cmds...) +} + +func (m *deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + case serviceUpdate: + if svc, exists := m.services[msg.name]; exists { + svc.status = msg.status + } + return m, nil + case spinner.TickMsg: + var cmds []tea.Cmd + for _, svc := range m.services { + var cmd tea.Cmd + svc.spinner, cmd = svc.spinner.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + return m, nil +} + +func (m *deploymentModel) View() string { + if m.quitting { + return "" + } + + var lines []string + // Sort services by name for consistent ordering + var serviceNames []string + for name := range m.services { + serviceNames = append(serviceNames, name) + } + sort.Strings(serviceNames) + + for _, name := range serviceNames { + svc := m.services[name] + + // Stop spinner for completed services + spinnerOrCheck := svc.spinner.View() + if svc.status == "DEPLOYMENT_COMPLETED" || svc.status == "DEPLOYMENT_FAILED" { + if svc.status == "DEPLOYMENT_COMPLETED" { + spinnerOrCheck = "✓" + } else { + spinnerOrCheck = "✗" + } + } + + line := lipgloss.JoinHorizontal( + lipgloss.Left, + spinnerOrCheck, + " ", + nameStyle.Render("["+name+"]"), + " ", + statusStyle.Render(svc.status), + ) + lines = append(lines, line) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func monitorWithUI(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (map[string]defangv1.ServiceState, error) { + // Get compute services to determine what to monitor + _, computeServices := splitManagedAndUnmanagedServices(project.Services) + + // Initialize the bubbletea model + model := newDeploymentModel(computeServices) + + // Create the bubbletea program + p := tea.NewProgram(model) + + var ( + serviceStates map[string]defangv1.ServiceState + monitorErr error + wg sync.WaitGroup + ) + wg.Add(2) // One for UI, one for monitoring + + // Start the bubbletea UI in a goroutine + go func() { + defer wg.Done() + if _, err := p.Run(); err != nil { + // Handle UI errors if needed + } + }() + + // Start monitoring in a goroutine + go func() { + defer wg.Done() + serviceStates, monitorErr = cli.Monitor(ctx, project, provider, waitTimeout, deploymentID, func(msg *defangv1.SubscribeResponse, states *cli.ServiceStates) error { + // Send service status updates to the bubbletea model + for name, state := range *states { + p.Send(serviceUpdate{ + name: name, + status: state.String(), + }) + } + return nil + }) + // Quit the UI when monitoring is done + p.Quit() + }() + + wg.Wait() + + return serviceStates, monitorErr +} + +func splitManagedAndUnmanagedServices(serviceInfos compose.Services) ([]string, []string) { + var managedServices []string + var unmanagedServices []string + for _, service := range serviceInfos { + if cli.CanMonitorService(&service) { + unmanagedServices = append(unmanagedServices, service.Name) + } else { + managedServices = append(managedServices, service.Name) + } + } + + return managedServices, unmanagedServices +} + func printPlaygroundPortalServiceURLs(serviceInfos []*defangv1.ServiceInfo) { // We can only show services deployed to the prod1 defang SaaS environment. if global.Stack.Provider == client.ProviderDefang && global.Cluster == client.DefaultCluster { @@ -195,7 +375,7 @@ func makeComposeUpCmd() *cobra.Command { } } else { term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") - serviceStates, err = cli.Monitor(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) + serviceStates, err = monitorWithUI(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) } if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err diff --git a/src/go.mod b/src/go.mod index 6fa7e88d3..ce6a2b3c4 100644 --- a/src/go.mod +++ b/src/go.mod @@ -34,6 +34,9 @@ require ( github.com/aws/smithy-go v1.24.0 github.com/awslabs/goformation/v7 v7.14.9 github.com/bufbuild/connect-go v1.10.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/compose-spec/compose-go/v2 v2.7.2-0.20250715094302-8da9902241f9 github.com/digitalocean/godo v1.131.1 github.com/docker/cli v27.3.1+incompatible @@ -50,7 +53,7 @@ require ( github.com/miekg/dns v1.1.59 github.com/moby/buildkit v0.17.3 github.com/moby/patternmatcher v0.6.0 - github.com/muesli/termenv v0.15.2 + github.com/muesli/termenv v0.16.0 github.com/openai/openai-go v1.12.0 github.com/opencontainers/image-spec v1.1.0 github.com/pelletier/go-toml/v2 v2.2.2 @@ -64,7 +67,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 golang.org/x/mod v0.25.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sys v0.34.0 + golang.org/x/sys v0.36.0 golang.org/x/term v0.33.0 google.golang.org/api v0.236.0 google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 @@ -89,6 +92,10 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect @@ -97,6 +104,7 @@ require ( github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect @@ -111,11 +119,14 @@ require ( github.com/invopop/jsonschema v0.13.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/rivo/uniseg v0.4.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect @@ -132,6 +143,7 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/src/go.sum b/src/go.sum index 2a5de7653..d9684ac25 100644 --- a/src/go.sum +++ b/src/go.sum @@ -122,6 +122,20 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/compose-spec/compose-go/v2 v2.7.2-0.20250715094302-8da9902241f9 h1:kqvhWCmg3fVAPbfE8aJdV+qX1VqK4oK/DRI5yxeVd4E= @@ -163,6 +177,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -262,8 +278,10 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= @@ -283,8 +301,12 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= @@ -307,8 +329,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= -github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/ross96D/cancelreader v0.2.6 h1:XLPWassoMWRTlHvEoVKS3z0N0a7jHcIupGU0U1gNArw= @@ -365,6 +387,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -408,6 +432,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -436,14 +462,15 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index da92273d5..f9694f054 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -15,7 +15,7 @@ import ( "github.com/bufbuild/connect-go" ) -func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (ServiceStates, error) { +func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string, watchCallback func(*defangv1.SubscribeResponse, *ServiceStates) error) (ServiceStates, error) { if deploymentID == "" { panic("deploymentID must be a valid deployment ID") } @@ -30,10 +30,6 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov _, computeServices := splitManagedAndUnmanagedServices(project.Services) - for _, svc := range computeServices { - term.Infof("[%s] %s\n", svc, "DEPLOYMENT_QUEUED") - } - var ( serviceStates ServiceStates cdErr, svcErr error @@ -43,13 +39,7 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov go func() { defer wg.Done() - serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { - // Print service status updates as they arrive - for name, state := range *states { - term.Infof("[%s] %s\n", name, state.String()) - } - return nil - }) + serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, watchCallback) }() go func() { @@ -82,7 +72,10 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie // Run Monitor in a goroutine go func() { - serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, tailOptions.Deployment) + // Pass a NOOP function for the callback since TailAndMonitor doesn't use UI + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, tailOptions.Deployment, func(*defangv1.SubscribeResponse, *ServiceStates) error { + return nil // NOOP - no UI updates needed when tailing + }) pkg.SleepWithContext(ctx, 2*time.Second) // a delay before cancelling tail to make sure we get last status messages cancelTail(errMonitoringDone) // cancel the tail when monitoring is done }() From 045bb3b92d376f64995b505332fcbc19a5e2e121 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 10:07:24 -0800 Subject: [PATCH 11/40] update nix vendor hash --- pkgs/defang/cli.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index a5015fc0a..c4abbf839 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,11 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-OBRF9HeyRMDmnKXKV9dj9i3lLs1oPujLOKP7cMb7sb4="; # TODO: use fetchFromGitHub +<<<<<<< HEAD + vendorHash = "sha256-0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # TODO: use fetchFromGitHub +======= + vendorHash = "sha256-0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # TODO: use fetchFromGitHub +>>>>>>> 1ff042b5 (update nix hash) subPackages = [ "cmd/cli" ]; From e91db51842cf82cf5c63fe5a5378ddcc33647158 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 6 Jan 2026 14:44:22 -0800 Subject: [PATCH 12/40] fix: resolve flaky TestWaitServiceState race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was flaky in CI due to a race condition in the select statement. When a stream ended after sending the final message (e.g., BUILD_FAILED), both msgChan and errChan could have data ready simultaneously: - msgChan had the failure state message - errChan had nil (from stream ending without error) The select statement could non-deterministically choose either channel, causing the test to sometimes return nil error instead of ErrDeploymentFailed. Fixed by: 1. Not sending nil errors to errChan when stream ends normally 2. Fixing misleading error message in test (cosmetic) đŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/pkg/cli/subscribe.go | 4 +++- src/pkg/cli/subscribe_test.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index eea111b66..9d02b98bb 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -68,7 +68,9 @@ func WatchServiceState( } continue } - errChan <- serverStream.Err() + if err := serverStream.Err(); err != nil { + errChan <- err + } return } diff --git a/src/pkg/cli/subscribe_test.go b/src/pkg/cli/subscribe_test.go index 8c14318d5..bcf0df907 100644 --- a/src/pkg/cli/subscribe_test.go +++ b/src/pkg/cli/subscribe_test.go @@ -223,7 +223,7 @@ func TestWaitServiceState(t *testing.T) { t.Run("Expect Error", func(t *testing.T) { ss, err := WaitServiceState(ctx, provider, tt.targetState, "testproject", tt.etag, tt.services) if err == nil { - t.Fatalf("Unexpected error: %v", err) + t.Fatal("Expected error but got nil") } if !errors.As(err, &client.ErrDeploymentFailed{}) { t.Errorf("Expected ErrDeploymentFailed but got: %v", err) From 8502bd2734b47d8689e188c32c378977643b7b0c Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Wed, 7 Jan 2026 16:54:03 -0800 Subject: [PATCH 13/40] refactor --- src/cmd/cli/command/compose.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 1160e458e..54fc93739 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -129,13 +129,14 @@ func (m *deploymentModel) View() string { svc := m.services[name] // Stop spinner for completed services - spinnerOrCheck := svc.spinner.View() - if svc.status == "DEPLOYMENT_COMPLETED" || svc.status == "DEPLOYMENT_FAILED" { - if svc.status == "DEPLOYMENT_COMPLETED" { - spinnerOrCheck = "✓" - } else { - spinnerOrCheck = "✗" - } + var spinnerOrCheck string + switch svc.status { + case "DEPLOYMENT_COMPLETED": + spinnerOrCheck = "✓" + case "DEPLOYMENT_FAILED": + spinnerOrCheck = "✗" + default: + spinnerOrCheck = svc.spinner.View() } line := lipgloss.JoinHorizontal( From c55a4794320307bba6119b8c3f4993b1ecb18af0 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Wed, 7 Jan 2026 17:12:26 -0800 Subject: [PATCH 14/40] use adaptive colors for light and dark backgrounds --- src/cmd/cli/command/compose.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 54fc93739..21535c67c 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -55,9 +55,9 @@ type serviceUpdate struct { } var ( - spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("206")) - statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) - nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#bc9724", Dark: "#2ddedc"}) + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#a4729d", Dark: "#fae856"}) + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#305897", Dark: "#cdd2c9"}) ) func newDeploymentModel(serviceNames []string) *deploymentModel { From 3a18250798274caaf30e3dd66116744797fbfad2 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Wed, 7 Jan 2026 17:18:56 -0800 Subject: [PATCH 15/40] avoid new --tail flag, use --verbose --- src/cmd/cli/command/compose.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 21535c67c..07164943c 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -241,7 +241,6 @@ func makeComposeUpCmd() *cobra.Command { var detach, _ = cmd.Flags().GetBool("detach") var utc, _ = cmd.Flags().GetBool("utc") var waitTimeout, _ = cmd.Flags().GetInt("wait-timeout") - var tail, _ = cmd.Flags().GetBool("tail") if utc { cli.EnableUTCMode() @@ -368,7 +367,7 @@ func makeComposeUpCmd() *cobra.Command { waitTimeoutDuration := time.Duration(waitTimeout) * time.Second var serviceStates map[string]defangv1.ServiceState - if tail { + if global.Verbose || global.NonInteractive { tailOptions.Follow = true serviceStates, err = cli.TailAndMonitor(ctx, project, session.Provider, waitTimeoutDuration, tailOptions) if err != nil { @@ -433,7 +432,6 @@ func makeComposeUpCmd() *cobra.Command { composeUpCmd.Flags().BoolP("detach", "d", false, "run in detached mode") composeUpCmd.Flags().Bool("force", false, "force a build of the image even if nothing has changed") composeUpCmd.Flags().Bool("utc", false, "show logs in UTC timezone (ie. TZ=UTC)") - composeUpCmd.Flags().Bool("tail", false, "tail the logs while deploying") composeUpCmd.Flags().VarP(&global.Stack.Mode, "mode", "m", fmt.Sprintf("deployment mode; one of %v", modes.AllDeploymentModes())) composeUpCmd.Flags().Bool("build", true, "build the image before starting the service") // docker-compose compatibility composeUpCmd.Flags().Bool("wait", true, "wait for services to be running|healthy") // docker-compose compatibility From 99bdbc57b2baa756ec8c4afef5e2c9380a7406dd Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Wed, 7 Jan 2026 17:22:11 -0800 Subject: [PATCH 16/40] include managed services --- src/cmd/cli/command/compose.go | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 07164943c..2af32f079 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -154,11 +154,13 @@ func (m *deploymentModel) View() string { } func monitorWithUI(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (map[string]defangv1.ServiceState, error) { - // Get compute services to determine what to monitor - _, computeServices := splitManagedAndUnmanagedServices(project.Services) + servicesNames := make([]string, 0, len(project.Services)) + for _, svc := range project.Services { + servicesNames = append(servicesNames, svc.Name) + } // Initialize the bubbletea model - model := newDeploymentModel(computeServices) + model := newDeploymentModel(servicesNames) // Create the bubbletea program p := tea.NewProgram(model) @@ -200,20 +202,6 @@ func monitorWithUI(ctx context.Context, project *compose.Project, provider clien return serviceStates, monitorErr } -func splitManagedAndUnmanagedServices(serviceInfos compose.Services) ([]string, []string) { - var managedServices []string - var unmanagedServices []string - for _, service := range serviceInfos { - if cli.CanMonitorService(&service) { - unmanagedServices = append(unmanagedServices, service.Name) - } else { - managedServices = append(managedServices, service.Name) - } - } - - return managedServices, unmanagedServices -} - func printPlaygroundPortalServiceURLs(serviceInfos []*defangv1.ServiceInfo) { // We can only show services deployed to the prod1 defang SaaS environment. if global.Stack.Provider == client.ProviderDefang && global.Cluster == client.DefaultCluster { From b9409c2d9583323f746a0a57d4add74ddb6c5b2b Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Wed, 7 Jan 2026 17:29:37 -0800 Subject: [PATCH 17/40] avoid printing unspecified service states --- src/pkg/cli/subscribe.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index 9d02b98bb..e79d8e7fa 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -101,7 +101,9 @@ func WatchServiceState( continue } - serviceStates[msg.Name] = msg.State + if msg.State != defangv1.ServiceState_NOT_SPECIFIED { + serviceStates[msg.Name] = msg.State + } err := cb(msg, &serviceStates) if err != nil { if errors.Is(err, client.ErrDeploymentSucceeded) { From 24c512779f99187cd0006588a266731c3772fe8a Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Wed, 7 Jan 2026 17:45:30 -0800 Subject: [PATCH 18/40] check healthchecks when summarizing deployments --- src/cmd/cli/command/compose.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 2af32f079..6b72108e8 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -407,9 +407,8 @@ func makeComposeUpCmd() *cobra.Command { } // Print the current service states of the deployment - err = cli.PrintServiceStatesAndEndpoints(deploy.Services) - if err != nil { - return err + if err := cli.PrintServices(cmd.Context(), project.Name, provider, false); err != nil { + term.Warn(err) } term.Info("Done.") From f51cce44dfe347972a2f809ad497c8fa720aea23 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Wed, 7 Jan 2026 17:46:34 -0800 Subject: [PATCH 19/40] avoid printing service "Status", only print "State" --- src/pkg/cli/deploymentinfo.go | 2 +- src/pkg/cli/deploymentinfo_test.go | 12 ++++++------ src/pkg/cli/getServices_test.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pkg/cli/deploymentinfo.go b/src/pkg/cli/deploymentinfo.go index 436ced596..f32f48e1e 100644 --- a/src/pkg/cli/deploymentinfo.go +++ b/src/pkg/cli/deploymentinfo.go @@ -58,7 +58,7 @@ func PrintServiceStatesAndEndpoints(serviceInfos []*defangv1.ServiceInfo) error return err } - attrs := []string{"Service", "Deployment", "State", "Fqdn", "Endpoint", "Status"} + attrs := []string{"Service", "Deployment", "State", "Fqdn", "Endpoint"} // if showDomainNameColumn { // attrs = append(attrs, "DomainName") // } diff --git a/src/pkg/cli/deploymentinfo_test.go b/src/pkg/cli/deploymentinfo_test.go index 70f809fcc..a12a01d6e 100644 --- a/src/pkg/cli/deploymentinfo_test.go +++ b/src/pkg/cli/deploymentinfo_test.go @@ -37,8 +37,8 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT STATUS", - "service1 NOT_SPECIFIED https://example.com UNKNOWN", + "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", + "service1 NOT_SPECIFIED https://example.com", "", }, }, @@ -58,8 +58,8 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT STATUS", - "service1 NOT_SPECIFIED https://example.com UNKNOWN", + "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", + "service1 NOT_SPECIFIED https://example.com", "", }, }, @@ -77,8 +77,8 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT STATUS", - "service1 NOT_SPECIFIED N/A UNKNOWN", + "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", + "service1 NOT_SPECIFIED N/A", "", }, }, diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index e1436bb42..23e686a19 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -86,8 +86,8 @@ func TestPrintServices(t *testing.T) { if err != nil { t.Fatalf("PrintServices error = %v", err) } - expectedOutput := "\x1b[95m * Checking service health...\n\x1b[0m\x1b[1m\nSERVICE DEPLOYMENT STATE FQDN ENDPOINT STATUS\x1b[0m" + ` -foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo.prod1.defang.dev NOT_SPECIFIED + expectedOutput := "\x1b[95m * Checking service health...\n\x1b[0m\x1b[1m\nSERVICE DEPLOYMENT STATE FQDN ENDPOINT\x1b[0m" + ` +foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo.prod1.defang.dev ` receivedLines := strings.Split(stdout.String(), "\n") From cc1df356a4e294814fa60e812a5ad3b8ccfe7d90 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Thu, 15 Jan 2026 10:28:14 -0800 Subject: [PATCH 20/40] fix references to session provider and stack --- src/cmd/cli/command/compose.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 6b72108e8..d9aa22d03 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -380,7 +380,7 @@ func makeComposeUpCmd() *cobra.Command { tailOptions.LogType = logs.LogTypeBuild tailOptions.Services = unbuiltServices } - err := cli.Tail(ctx, provider, project.Name, tailOptions) + err := cli.Tail(ctx, session.Provider, project.Name, tailOptions) if err != nil && !errors.Is(err, io.EOF) { term.Warn("Failed to tail logs for deployment error", err) return deploymentErr @@ -394,8 +394,8 @@ func makeComposeUpCmd() *cobra.Command { handleTailAndMonitorErr(ctx, deploymentErr, debugger, debug.DebugConfig{ Deployment: deploy.Etag, Project: project, - ProviderID: &global.Stack.Provider, - Stack: &global.Stack.Name, + ProviderID: &session.Stack.Provider, + Stack: &session.Stack.Name, Since: since, Until: time.Now(), }) @@ -407,7 +407,7 @@ func makeComposeUpCmd() *cobra.Command { } // Print the current service states of the deployment - if err := cli.PrintServices(cmd.Context(), project.Name, provider, false); err != nil { + if err := cli.PrintServices(cmd.Context(), project.Name, session.Provider, false); err != nil { term.Warn(err) } From d776f04a7c9bf7cd638e05368df33b4a2541e592 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 14:17:10 -0800 Subject: [PATCH 21/40] update nix vendor hash --- pkgs/defang/cli.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 8621ffa5e..15f267cce 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-A+DwCvfNUKY8TjxyAe+abiT9xIyy5p7VIh5T5ofeZIg="; # TODO: use fetchFromGitHub + vendorHash = "sha256-saGEuoB8Eeh/4SASPeyxW/xWArC7+oW88wUg6EJ39Fc="; # TODO: use fetchFromGitHub subPackages = [ "cmd/cli" ]; From 248e2fdf635af0575cbd2442d3c92526ebe71ecb Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 14:20:38 -0800 Subject: [PATCH 22/40] handle UploadModeDefault --- src/pkg/cli/compose/context.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pkg/cli/compose/context.go b/src/pkg/cli/compose/context.go index 9a8ba4ee7..ffbd799f5 100644 --- a/src/pkg/cli/compose/context.go +++ b/src/pkg/cli/compose/context.go @@ -223,6 +223,7 @@ func getRemoteBuildContext(ctx context.Context, provider client.Provider, projec var digest string switch upload { + case UploadModeDefault: case UploadModeDigest: // Calculate the digest of the tarball and pass it to the fabric controller (to avoid building the same image twice) sha := sha256.Sum256(buffer.Bytes()) From 9f1c00549e1b85627344c5dcfd057a917685d43b Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 14:49:29 -0800 Subject: [PATCH 23/40] report dns errors with healthcheck --- src/pkg/cli/getServices.go | 6 ++++++ src/pkg/cli/getServices_test.go | 15 ++++----------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index f962a8acd..2aba03ab1 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -2,7 +2,9 @@ package cli import ( "context" + "errors" "fmt" + "net" "net/http" "net/url" "strings" @@ -138,6 +140,10 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e term.Debugf("[%s] checking health at %s", name, url) resp, err := http.DefaultClient.Do(req) if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return "unhealthy (DNS error)", nil + } return "", err } defer resp.Body.Close() diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 2b2cb8cb1..97e533fff 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -363,7 +363,6 @@ func TestRunHealthcheck(t *testing.T) { endpoint string healthcheckPath string expectedStatus string - expectedErr bool }{ { name: "Healthy service", @@ -385,22 +384,16 @@ func TestRunHealthcheck(t *testing.T) { }, { name: "Invalid endpoint", - endpoint: "http://invalid-endpoint", - healthcheckPath: "/healthy", - expectedStatus: "", - expectedErr: true, + endpoint: "http://invalid-endpoint-238hf83wfnrewanf.com", + healthcheckPath: "/", + expectedStatus: "unhealthy (DNS error)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status, err := RunHealthcheck(ctx, "test-service", tt.endpoint, tt.healthcheckPath) - if tt.expectedErr { - require.Error(t, err) - return - } else { - require.NoError(t, err) - } + require.NoError(t, err) assert.Equal(t, tt.expectedStatus, status) }) } From 390af2a5ae22f81cf697e5a608f9392be28a524b Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 14:49:57 -0800 Subject: [PATCH 24/40] removing fqdn from service table --- src/pkg/agent/tools/services_test.go | 1 - src/pkg/cli/getServices.go | 8 +------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/pkg/agent/tools/services_test.go b/src/pkg/agent/tools/services_test.go index 5f7e2b6b7..78db9d170 100644 --- a/src/pkg/agent/tools/services_test.go +++ b/src/pkg/agent/tools/services_test.go @@ -231,7 +231,6 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { { Service: "test-service", Deployment: "test-deployment", - Fqdn: "test.example.com", Status: "running", }, }, diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 2aba03ab1..26ef00afa 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -22,7 +22,6 @@ type ServiceLineItem struct { Service string State defangv1.ServiceState Status string - Fqdn string AcmeCertUsed bool HealthcheckStatus string } @@ -162,10 +161,6 @@ func NewServiceFromServiceInfo(serviceInfos []*defangv1.ServiceInfo) ([]ServiceL // showDomainNameColumn := false for _, serviceInfo := range serviceInfos { - fqdn := serviceInfo.PublicFqdn - if fqdn == "" { - fqdn = serviceInfo.PrivateFqdn - } domainname := "N/A" if serviceInfo.Domainname != "" { // showDomainNameColumn = true @@ -182,7 +177,6 @@ func NewServiceFromServiceInfo(serviceInfos []*defangv1.ServiceInfo) ([]ServiceL State: serviceInfo.State, Status: serviceInfo.Status, Endpoint: domainname, - Fqdn: fqdn, AcmeCertUsed: serviceInfo.UseAcmeCert, } serviceTableItems = append(serviceTableItems, ps) @@ -203,7 +197,7 @@ func PrintServiceStatesAndEndpoints(services []ServiceLineItem) error { } } - attrs := []string{"Service", "Deployment", "State", "Fqdn", "Endpoint"} + attrs := []string{"Service", "Deployment", "State", "Endpoint"} if printHealthcheckStatus { attrs = append(attrs, "HealthcheckStatus") } From 9083a55297e0d5af759e27bf6ae9509413b434aa Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 14:55:07 -0800 Subject: [PATCH 25/40] suggest defang cert generate --- src/pkg/cli/getServices.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 26ef00afa..1742874ed 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -141,7 +141,8 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e if err != nil { var dnsErr *net.DNSError if errors.As(err, &dnsErr) { - return "unhealthy (DNS error)", nil + term.Warnf("service %q: Run `defang cert generate` to continue setup: %v", name, err) + return "unknown (DNS error)", nil } return "", err } From 7a1647bf83a7d7d2a55108fe7b0fc8f1bf206d90 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 14:58:54 -0800 Subject: [PATCH 26/40] warn about timeout --- src/pkg/cli/getServices.go | 3 +++ src/pkg/cli/getServices_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 1742874ed..7f7479996 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -140,6 +140,9 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e resp, err := http.DefaultClient.Do(req) if err != nil { var dnsErr *net.DNSError + if errors.Is(err, context.DeadlineExceeded) { + return "unknown (timeout)", nil + } if errors.As(err, &dnsErr) { term.Warnf("service %q: Run `defang cert generate` to continue setup: %v", name, err) return "unknown (DNS error)", nil diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 97e533fff..b55fa8359 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -386,7 +386,7 @@ func TestRunHealthcheck(t *testing.T) { name: "Invalid endpoint", endpoint: "http://invalid-endpoint-238hf83wfnrewanf.com", healthcheckPath: "/", - expectedStatus: "unhealthy (DNS error)", + expectedStatus: "unknown (DNS error)", }, } From 06c1bac0add62a4807460ab3a40c0f1d0b3f0293 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 15:22:33 -0800 Subject: [PATCH 27/40] detect tls errors --- src/pkg/cli/getServices.go | 8 +++++++- src/pkg/cli/getServices_test.go | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 7f7479996..51706297b 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -2,6 +2,7 @@ package cli import ( "context" + "crypto/tls" "errors" "fmt" "net" @@ -139,14 +140,19 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e term.Debugf("[%s] checking health at %s", name, url) resp, err := http.DefaultClient.Do(req) if err != nil { - var dnsErr *net.DNSError if errors.Is(err, context.DeadlineExceeded) { return "unknown (timeout)", nil } + var dnsErr *net.DNSError if errors.As(err, &dnsErr) { term.Warnf("service %q: Run `defang cert generate` to continue setup: %v", name, err) return "unknown (DNS error)", nil } + var tlsErr *tls.CertificateVerificationError + if errors.As(err, &tlsErr) { + term.Warnf("service %q: Run `defang cert generate` to continue setup: %v", name, err) + return "unknown (TLS certificate error)", nil + } return "", err } defer resp.Body.Close() diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index b55fa8359..3bf98c05e 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -398,3 +398,17 @@ func TestRunHealthcheck(t *testing.T) { }) } } + +func TestRunHealthcheckTLSError(t *testing.T) { + ctx := t.Context() + + // Start a test HTTPS server with a self-signed certificate + testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(testServer.Close) + + status, err := RunHealthcheck(ctx, "test-service", testServer.URL, "/healthy") + require.NoError(t, err) + assert.Equal(t, "unknown (TLS certificate error)", status) +} From a2077081b18d251111cc0a5b0900a3facaf3daf0 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 15:31:14 -0800 Subject: [PATCH 28/40] s/NewServiceFromServiceInfo/ServiceLineItemsFromServiceInfos/ --- src/pkg/cli/getServices.go | 9 +++++---- src/pkg/cli/getServices_test.go | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 51706297b..04e26d25f 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -60,13 +60,14 @@ func GetServices(ctx context.Context, projectName string, provider client.Provid return nil, err } - numServices := len(servicesResponse.Services) + serviceInfos := servicesResponse.Services + numServices := len(serviceInfos) if numServices == 0 { return nil, ErrNoServices{ProjectName: projectName} } - results := GetHealthcheckResults(ctx, servicesResponse.Services) - services, err := NewServiceFromServiceInfo(servicesResponse.Services) + endpointResults := GetHealthcheckResults(ctx, serviceInfos) + services, err := ServiceLineItemsFromServiceInfos(serviceInfos) if err != nil { return nil, err } @@ -165,7 +166,7 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e } } -func NewServiceFromServiceInfo(serviceInfos []*defangv1.ServiceInfo) ([]ServiceLineItem, error) { +func ServiceLineItemsFromServiceInfos(serviceInfos []*defangv1.ServiceInfo) ([]ServiceLineItem, error) { var serviceTableItems []ServiceLineItem // showDomainNameColumn := false diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 3bf98c05e..4a3235e0d 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -243,7 +243,7 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - services, err := NewServiceFromServiceInfo(tt.serviceinfos) + services, err := ServiceLineItemsFromServiceInfos(tt.serviceinfos) require.NoError(t, err) assert.Len(t, services, len(tt.expectedServices)) From 903a7625841d5f36d7bb4805aa54dd83bfc604b7 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 15:32:41 -0800 Subject: [PATCH 29/40] factor out ServiceLineItemFromServiceInfo --- src/pkg/cli/getServices.go | 46 +++++------ src/pkg/cli/getServices_test.go | 131 ++++++++++++++------------------ 2 files changed, 76 insertions(+), 101 deletions(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 04e26d25f..d73d0404e 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -166,31 +166,31 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e } } +func ServiceLineItemFromServiceInfo(serviceInfo *defangv1.ServiceInfo) ServiceLineItem { + domainname := "N/A" + if serviceInfo.Domainname != "" { + domainname = "https://" + serviceInfo.Domainname + } else if serviceInfo.PublicFqdn != "" { + domainname = "https://" + serviceInfo.PublicFqdn + } else if serviceInfo.PrivateFqdn != "" { + domainname = serviceInfo.PrivateFqdn + } + + return ServiceLineItem{ + Deployment: serviceInfo.Etag, + Service: serviceInfo.Service.Name, + State: serviceInfo.State, + Status: serviceInfo.Status, + Endpoint: domainname, + AcmeCertUsed: serviceInfo.UseAcmeCert, + } +} + func ServiceLineItemsFromServiceInfos(serviceInfos []*defangv1.ServiceInfo) ([]ServiceLineItem, error) { var serviceTableItems []ServiceLineItem - // showDomainNameColumn := false - for _, serviceInfo := range serviceInfos { - domainname := "N/A" - if serviceInfo.Domainname != "" { - // showDomainNameColumn = true - domainname = "https://" + serviceInfo.Domainname - } else if serviceInfo.PublicFqdn != "" { - domainname = "https://" + serviceInfo.PublicFqdn - } else if serviceInfo.PrivateFqdn != "" { - domainname = serviceInfo.PrivateFqdn - } - - ps := ServiceLineItem{ - Deployment: serviceInfo.Etag, - Service: serviceInfo.Service.Name, - State: serviceInfo.State, - Status: serviceInfo.Status, - Endpoint: domainname, - AcmeCertUsed: serviceInfo.UseAcmeCert, - } - serviceTableItems = append(serviceTableItems, ps) + serviceTableItems = append(serviceTableItems, ServiceLineItemFromServiceInfo(serviceInfo)) } return serviceTableItems, nil @@ -212,10 +212,6 @@ func PrintServiceStatesAndEndpoints(services []ServiceLineItem) error { if printHealthcheckStatus { attrs = append(attrs, "HealthcheckStatus") } - // if showDomainNameColumn { - // attrs = append(attrs, "DomainName") - // } - err := term.Table(services, attrs...) if err != nil { return err diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 4a3235e0d..77bbdb4fa 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -149,110 +149,89 @@ foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo. func TestGetServiceStatesAndEndpoints(t *testing.T) { tests := []struct { - name string - serviceinfos []*defangv1.ServiceInfo - expectedServices []ServiceLineItem + name string + serviceinfo *defangv1.ServiceInfo + expectedService ServiceLineItem }{ { name: "empty endpoint list", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - Domainname: "example.com", - Endpoints: []string{}, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", }, + Status: "UNKNOWN", + Domainname: "example.com", + Endpoints: []string{}, }, - expectedServices: []ServiceLineItem{ - { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", - }, + expectedService: ServiceLineItem{ + Service: "service1", + Status: "UNKNOWN", + Endpoint: "https://example.com", }, }, { name: "Service with Domainname", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - Domainname: "example.com", - Endpoints: []string{ - "example.com", - "service1.internal:80", - }, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", }, - }, - expectedServices: []ServiceLineItem{ - { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", + Status: "UNKNOWN", + Domainname: "example.com", + Endpoints: []string{ + "example.com", + "service1.internal:80", }, }, + expectedService: ServiceLineItem{ + Service: "service1", + Status: "UNKNOWN", + Endpoint: "https://example.com", + }, }, { name: "endpoint without port", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - Endpoints: []string{ - "service1", - }, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", }, - }, - expectedServices: []ServiceLineItem{ - { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "N/A", + Status: "UNKNOWN", + Endpoints: []string{ + "service1", }, }, + expectedService: ServiceLineItem{ + Service: "service1", + Status: "UNKNOWN", + Endpoint: "N/A", + }, }, { name: "with acme cert", - serviceinfos: []*defangv1.ServiceInfo{ - { - Service: &defangv1.Service{ - Name: "service1", - }, - Status: "UNKNOWN", - UseAcmeCert: true, - Endpoints: []string{ - "service1", - }, + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", }, - }, - expectedServices: []ServiceLineItem{ - { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "N/A", - AcmeCertUsed: true, + Status: "UNKNOWN", + UseAcmeCert: true, + Endpoints: []string{ + "service1", }, }, + expectedService: ServiceLineItem{ + Service: "service1", + Status: "UNKNOWN", + Endpoint: "N/A", + AcmeCertUsed: true, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - services, err := ServiceLineItemsFromServiceInfos(tt.serviceinfos) - require.NoError(t, err) - - assert.Len(t, services, len(tt.expectedServices)) - for i, svc := range services { - assert.Equal(t, tt.expectedServices[i].Service, svc.Service) - assert.Equal(t, tt.expectedServices[i].Status, svc.Status) - assert.Equal(t, tt.expectedServices[i].Endpoint, svc.Endpoint) - assert.Equal(t, tt.expectedServices[i].AcmeCertUsed, svc.AcmeCertUsed) - } + svc := ServiceLineItemFromServiceInfo(tt.serviceinfo) + assert.Equal(t, tt.expectedService.Service, svc.Service) + assert.Equal(t, tt.expectedService.Status, svc.Status) + assert.Equal(t, tt.expectedService.Endpoint, svc.Endpoint) + assert.Equal(t, tt.expectedService.AcmeCertUsed, svc.AcmeCertUsed) }) } } From 84d12a5c1a31ba0b2c0949410f2c905415ed7750 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 15:39:47 -0800 Subject: [PATCH 30/40] s/ServiceLineItem/ServiceEndpoint/ --- src/pkg/agent/tools/default_tool_cli.go | 2 +- src/pkg/agent/tools/interfaces.go | 2 +- src/pkg/agent/tools/services_test.go | 6 +++--- src/pkg/cli/getServices.go | 18 +++++++++--------- src/pkg/cli/getServices_test.go | 22 +++++++++++----------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/pkg/agent/tools/default_tool_cli.go b/src/pkg/agent/tools/default_tool_cli.go index 33fae50af..a7d4bb49a 100644 --- a/src/pkg/agent/tools/default_tool_cli.go +++ b/src/pkg/agent/tools/default_tool_cli.go @@ -70,7 +70,7 @@ func (DefaultToolCLI) ConfigDelete(ctx context.Context, projectName string, prov return cli.ConfigDelete(ctx, projectName, provider, name) } -func (DefaultToolCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceLineItem, error) { +func (DefaultToolCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceEndpoint, error) { return cli.GetServices(ctx, projectName, provider) } diff --git a/src/pkg/agent/tools/interfaces.go b/src/pkg/agent/tools/interfaces.go index b05aaa6d5..3f52acd16 100644 --- a/src/pkg/agent/tools/interfaces.go +++ b/src/pkg/agent/tools/interfaces.go @@ -21,7 +21,7 @@ type CLIInterface interface { Connect(ctx context.Context, cluster string) (*client.GrpcClient, error) CreatePlaygroundProvider(fabric *client.GrpcClient) client.Provider GenerateAuthURL(authPort int) string - GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceLineItem, error) + GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceEndpoint, error) InteractiveLoginMCP(ctx context.Context, cluster string, mcpClient string) error ListConfig(ctx context.Context, provider client.Provider, projectName string) (*defangv1.Secrets, error) LoadProject(ctx context.Context, loader client.Loader) (*compose.Project, error) diff --git a/src/pkg/agent/tools/services_test.go b/src/pkg/agent/tools/services_test.go index 78db9d170..31075ab7a 100644 --- a/src/pkg/agent/tools/services_test.go +++ b/src/pkg/agent/tools/services_test.go @@ -29,7 +29,7 @@ type MockCLI struct { MockProjectName string GetServicesError error - MockServices []cli.ServiceLineItem + MockServices []cli.ServiceEndpoint GetServicesCalled bool GetServicesProject string GetServicesProvider client.Provider @@ -56,7 +56,7 @@ func (m *MockCLI) LoadProjectNameWithFallback(ctx context.Context, loader client return "default-project", nil } -func (m *MockCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceLineItem, error) { +func (m *MockCLI) GetServices(ctx context.Context, projectName string, provider client.Provider) ([]cli.ServiceEndpoint, error) { m.GetServicesCalled = true m.GetServicesProject = projectName m.GetServicesProvider = provider @@ -227,7 +227,7 @@ func TestHandleServicesToolWithMockCLI(t *testing.T) { MockClient: &client.GrpcClient{}, MockProvider: &client.PlaygroundProvider{}, MockProjectName: "test-project", - MockServices: []cli.ServiceLineItem{ + MockServices: []cli.ServiceEndpoint{ { Service: "test-service", Deployment: "test-deployment", diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index d73d0404e..16a154717 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -17,7 +17,7 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -type ServiceLineItem struct { +type ServiceEndpoint struct { Deployment string Endpoint string Service string @@ -52,7 +52,7 @@ func PrintLongServices(ctx context.Context, projectName string, provider client. return PrintObject("", servicesResponse) } -func GetServices(ctx context.Context, projectName string, provider client.Provider) ([]ServiceLineItem, error) { +func GetServices(ctx context.Context, projectName string, provider client.Provider) ([]ServiceEndpoint, error) { term.Debugf("Listing services in project %q", projectName) servicesResponse, err := provider.GetServices(ctx, &defangv1.GetServicesRequest{Project: projectName}) @@ -67,7 +67,7 @@ func GetServices(ctx context.Context, projectName string, provider client.Provid } endpointResults := GetHealthcheckResults(ctx, serviceInfos) - services, err := ServiceLineItemsFromServiceInfos(serviceInfos) + services, err := ServiceEndpointsFromServiceInfos(serviceInfos) if err != nil { return nil, err } @@ -166,7 +166,7 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e } } -func ServiceLineItemFromServiceInfo(serviceInfo *defangv1.ServiceInfo) ServiceLineItem { +func ServiceEndpointsFromServiceInfo(serviceInfo *defangv1.ServiceInfo) ServiceEndpoint { domainname := "N/A" if serviceInfo.Domainname != "" { domainname = "https://" + serviceInfo.Domainname @@ -176,7 +176,7 @@ func ServiceLineItemFromServiceInfo(serviceInfo *defangv1.ServiceInfo) ServiceLi domainname = serviceInfo.PrivateFqdn } - return ServiceLineItem{ + return ServiceEndpoint{ Deployment: serviceInfo.Etag, Service: serviceInfo.Service.Name, State: serviceInfo.State, @@ -186,17 +186,17 @@ func ServiceLineItemFromServiceInfo(serviceInfo *defangv1.ServiceInfo) ServiceLi } } -func ServiceLineItemsFromServiceInfos(serviceInfos []*defangv1.ServiceInfo) ([]ServiceLineItem, error) { - var serviceTableItems []ServiceLineItem +func ServiceEndpointsFromServiceInfos(serviceInfos []*defangv1.ServiceInfo) ([]ServiceEndpoint, error) { + var serviceTableItems []ServiceEndpoint for _, serviceInfo := range serviceInfos { - serviceTableItems = append(serviceTableItems, ServiceLineItemFromServiceInfo(serviceInfo)) + serviceTableItems = append(serviceTableItems, ServiceEndpointsFromServiceInfo(serviceInfo)) } return serviceTableItems, nil } -func PrintServiceStatesAndEndpoints(services []ServiceLineItem) error { +func PrintServiceStatesAndEndpoints(services []ServiceEndpoint) error { showCertGenerateHint := false printHealthcheckStatus := false for _, svc := range services { diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 77bbdb4fa..c881172e4 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -147,11 +147,11 @@ foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo. }) } -func TestGetServiceStatesAndEndpoints(t *testing.T) { +func ServiceEndpointFromServiceInfo(t *testing.T) { tests := []struct { name string serviceinfo *defangv1.ServiceInfo - expectedService ServiceLineItem + expectedService ServiceEndpoint }{ { name: "empty endpoint list", @@ -163,7 +163,7 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { Domainname: "example.com", Endpoints: []string{}, }, - expectedService: ServiceLineItem{ + expectedService: ServiceEndpoint{ Service: "service1", Status: "UNKNOWN", Endpoint: "https://example.com", @@ -182,7 +182,7 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { "service1.internal:80", }, }, - expectedService: ServiceLineItem{ + expectedService: ServiceEndpoint{ Service: "service1", Status: "UNKNOWN", Endpoint: "https://example.com", @@ -199,7 +199,7 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { "service1", }, }, - expectedService: ServiceLineItem{ + expectedService: ServiceEndpoint{ Service: "service1", Status: "UNKNOWN", Endpoint: "N/A", @@ -217,7 +217,7 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { "service1", }, }, - expectedService: ServiceLineItem{ + expectedService: ServiceEndpoint{ Service: "service1", Status: "UNKNOWN", Endpoint: "N/A", @@ -227,7 +227,7 @@ func TestGetServiceStatesAndEndpoints(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc := ServiceLineItemFromServiceInfo(tt.serviceinfo) + svc := ServiceEndpointsFromServiceInfo(tt.serviceinfo) assert.Equal(t, tt.expectedService.Service, svc.Service) assert.Equal(t, tt.expectedService.Status, svc.Status) assert.Equal(t, tt.expectedService.Endpoint, svc.Endpoint) @@ -247,12 +247,12 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { tests := []struct { name string - services []ServiceLineItem + services []ServiceEndpoint expectedLines []string }{ { name: "empty endpoint list", - services: []ServiceLineItem{ + services: []ServiceEndpoint{ { Service: "service1", Status: "UNKNOWN", @@ -267,7 +267,7 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, { name: "Service with Domainname", - services: []ServiceLineItem{ + services: []ServiceEndpoint{ { Service: "service1", Status: "UNKNOWN", @@ -282,7 +282,7 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, { name: "with acme cert", - services: []ServiceLineItem{ + services: []ServiceEndpoint{ { Service: "service1", Status: "UNKNOWN", From 01aee7cfd17f7282c1e62808d559ab9fc902bc7e Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 16 Jan 2026 16:39:21 -0800 Subject: [PATCH 31/40] endpoint-oriented service listing --- src/pkg/cli/getServices.go | 109 ++++++++++++-------------- src/pkg/cli/getServices_test.go | 132 ++++++++++++++++++++++++-------- 2 files changed, 150 insertions(+), 91 deletions(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 16a154717..777f3cd12 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -24,6 +24,7 @@ type ServiceEndpoint struct { State defangv1.ServiceState Status string AcmeCertUsed bool + HealthcheckPath string HealthcheckStatus string } @@ -66,19 +67,12 @@ func GetServices(ctx context.Context, projectName string, provider client.Provid return nil, ErrNoServices{ProjectName: projectName} } - endpointResults := GetHealthcheckResults(ctx, serviceInfos) - services, err := ServiceEndpointsFromServiceInfos(serviceInfos) + serviceEndpoints, err := ServiceEndpointsFromServiceInfos(serviceInfos) if err != nil { return nil, err } - for i, svc := range services { - if status, ok := results[svc.Service]; ok { - services[i].HealthcheckStatus = *status - } else { - services[i].HealthcheckStatus = "unknown" - } - } - return services, nil + UpdateHealthcheckResults(ctx, serviceEndpoints) + return serviceEndpoints, nil } func PrintServices(ctx context.Context, projectName string, provider client.Provider) error { @@ -90,42 +84,30 @@ func PrintServices(ctx context.Context, projectName string, provider client.Prov return PrintServiceStatesAndEndpoints(services) } -type HealthCheckResults map[string]*string - -func GetHealthcheckResults(ctx context.Context, serviceInfos []*defangv1.ServiceInfo) HealthCheckResults { +func UpdateHealthcheckResults(ctx context.Context, serviceEndpoints []ServiceEndpoint) { // Create a context with a timeout for HTTP requests ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() var wg sync.WaitGroup - results := make(HealthCheckResults) - for _, serviceInfo := range serviceInfos { - results[serviceInfo.Service.Name] = (new(string)) - } - - for _, serviceInfo := range serviceInfos { - for _, endpoint := range serviceInfo.Endpoints { - if strings.Contains(endpoint, ":") { - *results[serviceInfo.Service.Name] = "skipped" - // Skip endpoints with ports because they likely non-HTTP services - continue - } - wg.Add(1) - go func(serviceInfo *defangv1.ServiceInfo) { - defer wg.Done() - result, err := RunHealthcheck(ctx, serviceInfo.Service.Name, "https://"+endpoint, serviceInfo.HealthcheckPath) - if err != nil { - term.Debugf("Healthcheck error for service %q at endpoint %q: %s", serviceInfo.Service.Name, endpoint, err.Error()) - result = "error" - } - *results[serviceInfo.Service.Name] = result - }(serviceInfo) + for i, serviceEndpoint := range serviceEndpoints { + if strings.Contains(serviceEndpoint.Endpoint, ":") && !strings.HasPrefix(serviceEndpoint.Endpoint, "https://") { + serviceEndpoints[i].HealthcheckStatus = "-" + continue } + wg.Add(1) + go func(serviceEndpoint *ServiceEndpoint) { + defer wg.Done() + result, err := RunHealthcheck(ctx, serviceEndpoint.Service, serviceEndpoint.Endpoint, serviceEndpoint.HealthcheckPath) + if err != nil { + term.Debugf("Healthcheck error for service %q at endpoint %q: %s", serviceEndpoint.Service, serviceEndpoint.Endpoint, err.Error()) + result = "error" + } + serviceEndpoint.HealthcheckStatus = result + }(&serviceEndpoints[i]) } wg.Wait() - - return results } func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, error) { @@ -166,40 +148,51 @@ func RunHealthcheck(ctx context.Context, name, endpoint, path string) (string, e } } -func ServiceEndpointsFromServiceInfo(serviceInfo *defangv1.ServiceInfo) ServiceEndpoint { - domainname := "N/A" - if serviceInfo.Domainname != "" { - domainname = "https://" + serviceInfo.Domainname - } else if serviceInfo.PublicFqdn != "" { - domainname = "https://" + serviceInfo.PublicFqdn - } else if serviceInfo.PrivateFqdn != "" { - domainname = serviceInfo.PrivateFqdn - } - - return ServiceEndpoint{ - Deployment: serviceInfo.Etag, - Service: serviceInfo.Service.Name, - State: serviceInfo.State, - Status: serviceInfo.Status, - Endpoint: domainname, - AcmeCertUsed: serviceInfo.UseAcmeCert, +func ServiceEndpointsFromServiceInfo(serviceInfo *defangv1.ServiceInfo) []ServiceEndpoint { + endpoints := make([]ServiceEndpoint, 0, len(serviceInfo.Endpoints)+1) + for _, endpoint := range serviceInfo.Endpoints { + _, port, _ := net.SplitHostPort(endpoint) + if port == "" { + endpoint = "https://" + strings.TrimPrefix(endpoint, "https://") + } + endpoints = append(endpoints, ServiceEndpoint{ + Deployment: serviceInfo.Etag, + Service: serviceInfo.Service.Name, + State: serviceInfo.State, + Status: serviceInfo.Status, + Endpoint: endpoint, + HealthcheckPath: serviceInfo.HealthcheckPath, + AcmeCertUsed: serviceInfo.UseAcmeCert, + }) } + if serviceInfo.Domainname != "" { + endpoints = append(endpoints, ServiceEndpoint{ + Deployment: serviceInfo.Etag, + Service: serviceInfo.Service.Name, + State: serviceInfo.State, + Status: serviceInfo.Status, + Endpoint: "https://" + serviceInfo.Domainname, + HealthcheckPath: serviceInfo.HealthcheckPath, + AcmeCertUsed: serviceInfo.UseAcmeCert, + }) + } + return endpoints } func ServiceEndpointsFromServiceInfos(serviceInfos []*defangv1.ServiceInfo) ([]ServiceEndpoint, error) { var serviceTableItems []ServiceEndpoint for _, serviceInfo := range serviceInfos { - serviceTableItems = append(serviceTableItems, ServiceEndpointsFromServiceInfo(serviceInfo)) + serviceTableItems = append(serviceTableItems, ServiceEndpointsFromServiceInfo(serviceInfo)...) } return serviceTableItems, nil } -func PrintServiceStatesAndEndpoints(services []ServiceEndpoint) error { +func PrintServiceStatesAndEndpoints(serviceEndpoints []ServiceEndpoint) error { showCertGenerateHint := false printHealthcheckStatus := false - for _, svc := range services { + for _, svc := range serviceEndpoints { if svc.AcmeCertUsed { showCertGenerateHint = true } @@ -212,7 +205,7 @@ func PrintServiceStatesAndEndpoints(services []ServiceEndpoint) error { if printHealthcheckStatus { attrs = append(attrs, "HealthcheckStatus") } - err := term.Table(services, attrs...) + err := term.Table(serviceEndpoints, attrs...) if err != nil { return err } diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index c881172e4..e59357da8 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -91,8 +91,8 @@ func TestPrintServices(t *testing.T) { if err != nil { t.Fatalf("PrintServices error = %v", err) } - expectedOutput := "\x1b[1m\nSERVICE DEPLOYMENT STATE FQDN ENDPOINT HEALTHCHECKSTATUS\x1b[0m" + ` -foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo.prod1.defang.dev unhealthy (404 Not Found) + expectedOutput := "\x1b[1m\nSERVICE DEPLOYMENT STATE ENDPOINT HEALTHCHECKSTATUS\x1b[0m" + ` +foo a1b2c3 NOT_SPECIFIED https://test-foo--3000.prod1.defang.dev unhealthy (404 Not Found) ` receivedLines := strings.Split(stdout.String(), "\n") @@ -149,9 +149,9 @@ foo a1b2c3 NOT_SPECIFIED test-foo.prod1.defang.dev https://test-foo. func ServiceEndpointFromServiceInfo(t *testing.T) { tests := []struct { - name string - serviceinfo *defangv1.ServiceInfo - expectedService ServiceEndpoint + name string + serviceinfo *defangv1.ServiceInfo + expectedServiceEndpoints []ServiceEndpoint }{ { name: "empty endpoint list", @@ -163,10 +163,12 @@ func ServiceEndpointFromServiceInfo(t *testing.T) { Domainname: "example.com", Endpoints: []string{}, }, - expectedService: ServiceEndpoint{ - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "https://example.com", + }, }, }, { @@ -182,10 +184,12 @@ func ServiceEndpointFromServiceInfo(t *testing.T) { "service1.internal:80", }, }, - expectedService: ServiceEndpoint{ - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "https://example.com", + }, }, }, { @@ -199,10 +203,12 @@ func ServiceEndpointFromServiceInfo(t *testing.T) { "service1", }, }, - expectedService: ServiceEndpoint{ - Service: "service1", - Status: "UNKNOWN", - Endpoint: "N/A", + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "N/A", + }, }, }, { @@ -217,21 +223,81 @@ func ServiceEndpointFromServiceInfo(t *testing.T) { "service1", }, }, - expectedService: ServiceEndpoint{ - Service: "service1", - Status: "UNKNOWN", - Endpoint: "N/A", - AcmeCertUsed: true, + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "N/A", + AcmeCertUsed: true, + }, + }, + }, + { + name: "with multiple endpoints", + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", + }, + Status: "UNKNOWN", + Endpoints: []string{ + "service1:80", + "service1.internal:8080", + }, + }, + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1:80", + }, + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1.internal:8080", + }, + }, + }, + { + name: "with multiple endpoints and domainname", + serviceinfo: &defangv1.ServiceInfo{ + Service: &defangv1.Service{ + Name: "service1", + }, + Status: "UNKNOWN", + Domainname: "example.com", + Endpoints: []string{ + "service1:80", + "service1.internal:8080", + }, + }, + expectedServiceEndpoints: []ServiceEndpoint{ + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1:80", + }, + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "http://service1.internal:8080", + }, + { + Service: "service1", + Status: "UNKNOWN", + Endpoint: "https://example.com", + }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc := ServiceEndpointsFromServiceInfo(tt.serviceinfo) - assert.Equal(t, tt.expectedService.Service, svc.Service) - assert.Equal(t, tt.expectedService.Status, svc.Status) - assert.Equal(t, tt.expectedService.Endpoint, svc.Endpoint) - assert.Equal(t, tt.expectedService.AcmeCertUsed, svc.AcmeCertUsed) + serviceEndpoints := ServiceEndpointsFromServiceInfo(tt.serviceinfo) + for i, endpoint := range serviceEndpoints { + assert.Equal(t, tt.expectedServiceEndpoints[i].Service, endpoint.Service) + assert.Equal(t, tt.expectedServiceEndpoints[i].Status, endpoint.Status) + assert.Equal(t, tt.expectedServiceEndpoints[i].Endpoint, endpoint.Endpoint) + assert.Equal(t, tt.expectedServiceEndpoints[i].AcmeCertUsed, endpoint.AcmeCertUsed) + } }) } } @@ -260,8 +326,8 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", - "service1 NOT_SPECIFIED https://example.com", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 NOT_SPECIFIED https://example.com", "", }, }, @@ -275,8 +341,8 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", - "service1 NOT_SPECIFIED https://example.com", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 NOT_SPECIFIED https://example.com", "", }, }, @@ -291,8 +357,8 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE FQDN ENDPOINT", - "service1 NOT_SPECIFIED N/A", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 NOT_SPECIFIED N/A", " * Run `defang cert generate` to get a TLS certificate for your service(s)", "", }, From 17d8cbff9b4804dcfb4e9d70d8e7340a37f37924 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 10:57:50 -0800 Subject: [PATCH 32/40] add space after check or "x" to preserve alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, partially completed deployments appeared like this: ``` * Live tail logs with `defang tail --deployment=6k69je47hxiy` âąż [app] DEPLOYMENT_PENDING ✓ [embedding] DEPLOYMENT_COMPLETED ✓ [llm] DEPLOYMENT_COMPLETED âąż [postgres] DEPLOYMENT_QUEUED âąż [redis] DEPLOYMENT_QUEUED ✓ [worker] DEPLOYMENT_COMPLETED ``` --- src/cmd/cli/command/compose.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 99f03e2c4..5c0ee9ed2 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -132,9 +132,9 @@ func (m *deploymentModel) View() string { var spinnerOrCheck string switch svc.status { case "DEPLOYMENT_COMPLETED": - spinnerOrCheck = "✓" + spinnerOrCheck = "✓ " case "DEPLOYMENT_FAILED": - spinnerOrCheck = "✗" + spinnerOrCheck = "✗ " default: spinnerOrCheck = svc.spinner.View() } From 9175a8e09acedcffc4c9c2984d173811b5b88012 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 09:49:06 -0800 Subject: [PATCH 33/40] avoid printing repeated service row text --- src/pkg/cli/getServices.go | 56 +++++++++++++++++++++-------- src/pkg/cli/getServices_test.go | 63 +++++++++++++++++++++++++-------- 2 files changed, 89 insertions(+), 30 deletions(-) diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index 777f3cd12..dae51f65f 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -18,14 +19,14 @@ import ( ) type ServiceEndpoint struct { - Deployment string - Endpoint string - Service string - State defangv1.ServiceState - Status string - AcmeCertUsed bool - HealthcheckPath string - HealthcheckStatus string + Deployment string + Endpoint string + Service string + State string + Status string + AcmeCertUsed bool + HealthcheckPath string + Healthcheck string // status } type ErrNoServices struct { @@ -92,7 +93,7 @@ func UpdateHealthcheckResults(ctx context.Context, serviceEndpoints []ServiceEnd for i, serviceEndpoint := range serviceEndpoints { if strings.Contains(serviceEndpoint.Endpoint, ":") && !strings.HasPrefix(serviceEndpoint.Endpoint, "https://") { - serviceEndpoints[i].HealthcheckStatus = "-" + serviceEndpoints[i].Healthcheck = "-" continue } wg.Add(1) @@ -103,7 +104,7 @@ func UpdateHealthcheckResults(ctx context.Context, serviceEndpoints []ServiceEnd term.Debugf("Healthcheck error for service %q at endpoint %q: %s", serviceEndpoint.Service, serviceEndpoint.Endpoint, err.Error()) result = "error" } - serviceEndpoint.HealthcheckStatus = result + serviceEndpoint.Healthcheck = result }(&serviceEndpoints[i]) } @@ -158,7 +159,7 @@ func ServiceEndpointsFromServiceInfo(serviceInfo *defangv1.ServiceInfo) []Servic endpoints = append(endpoints, ServiceEndpoint{ Deployment: serviceInfo.Etag, Service: serviceInfo.Service.Name, - State: serviceInfo.State, + State: serviceInfo.State.String(), Status: serviceInfo.Status, Endpoint: endpoint, HealthcheckPath: serviceInfo.HealthcheckPath, @@ -169,7 +170,7 @@ func ServiceEndpointsFromServiceInfo(serviceInfo *defangv1.ServiceInfo) []Servic endpoints = append(endpoints, ServiceEndpoint{ Deployment: serviceInfo.Etag, Service: serviceInfo.Service.Name, - State: serviceInfo.State, + State: serviceInfo.State.String(), Status: serviceInfo.Status, Endpoint: "https://" + serviceInfo.Domainname, HealthcheckPath: serviceInfo.HealthcheckPath, @@ -196,14 +197,39 @@ func PrintServiceStatesAndEndpoints(serviceEndpoints []ServiceEndpoint) error { if svc.AcmeCertUsed { showCertGenerateHint = true } - if svc.HealthcheckStatus != "" { + if svc.Healthcheck != "" { printHealthcheckStatus = true } } - attrs := []string{"Service", "Deployment", "State", "Endpoint"} + attrs := []string{"Service", "Deployment", "State"} if printHealthcheckStatus { - attrs = append(attrs, "HealthcheckStatus") + attrs = append(attrs, "Healthcheck", "Endpoint") + } else { + attrs = append(attrs, "Endpoint") + } + + // sort serviceEndpoints by Service, Deployment, Endpoint + slices.SortStableFunc(serviceEndpoints, func(a, b ServiceEndpoint) int { + if a.Service != b.Service { + return strings.Compare(a.Service, b.Service) + } + if a.Deployment != b.Deployment { + return strings.Compare(a.Deployment, b.Deployment) + } + return strings.Compare(a.Endpoint, b.Endpoint) + }) + + // remove "Service", "Deployment", and "State" columns if they are the same as the previous row + lastService := "" + for i := range serviceEndpoints { + if serviceEndpoints[i].Service == lastService { + serviceEndpoints[i].Service = "" + serviceEndpoints[i].Deployment = "" + serviceEndpoints[i].State = "" + } else { + lastService = serviceEndpoints[i].Service + } } err := term.Table(serviceEndpoints, attrs...) if err != nil { diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index e59357da8..5462cfd6e 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -91,8 +91,8 @@ func TestPrintServices(t *testing.T) { if err != nil { t.Fatalf("PrintServices error = %v", err) } - expectedOutput := "\x1b[1m\nSERVICE DEPLOYMENT STATE ENDPOINT HEALTHCHECKSTATUS\x1b[0m" + ` -foo a1b2c3 NOT_SPECIFIED https://test-foo--3000.prod1.defang.dev unhealthy (404 Not Found) + expectedOutput := "\x1b[1m\nSERVICE DEPLOYMENT STATE HEALTHCHECK ENDPOINT\x1b[0m" + ` +foo a1b2c3 NOT_SPECIFIED unhealthy (404 Not Found) https://test-foo--3000.prod1.defang.dev ` receivedLines := strings.Split(stdout.String(), "\n") @@ -320,14 +320,15 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { name: "empty endpoint list", services: []ServiceEndpoint{ { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", + Service: "service1", + State: "DEPLOYMENT_COMPLETED", + Endpoint: "https://example.com", + Deployment: "abcd1234", }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE ENDPOINT", - "service1 NOT_SPECIFIED https://example.com", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED https://example.com", "", }, }, @@ -335,14 +336,15 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { name: "Service with Domainname", services: []ServiceEndpoint{ { - Service: "service1", - Status: "UNKNOWN", - Endpoint: "https://example.com", + Service: "service1", + Endpoint: "https://example.com", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE ENDPOINT", - "service1 NOT_SPECIFIED https://example.com", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED https://example.com", "", }, }, @@ -351,18 +353,49 @@ func TestPrintServiceStatesAndEndpointsAndDomainname(t *testing.T) { services: []ServiceEndpoint{ { Service: "service1", - Status: "UNKNOWN", Endpoint: "N/A", AcmeCertUsed: true, + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", }, }, expectedLines: []string{ - "SERVICE DEPLOYMENT STATE ENDPOINT", - "service1 NOT_SPECIFIED N/A", + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED N/A", " * Run `defang cert generate` to get a TLS certificate for your service(s)", "", }, }, + { + name: "with multiple endpoints and domainname", + services: []ServiceEndpoint{ + { + Service: "service1", + Endpoint: "http://service1:80", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", + }, + { + Service: "service1", + Endpoint: "http://service1.internal:8080", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", + }, + { + Service: "service1", + Endpoint: "https://example.com", + Deployment: "abcd1234", + State: "DEPLOYMENT_COMPLETED", + }, + }, + expectedLines: []string{ + "SERVICE DEPLOYMENT STATE ENDPOINT", + "service1 abcd1234 DEPLOYMENT_COMPLETED http://service1.internal:8080", + " http://service1:80", + " https://example.com", + "", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 3577c52d4610c15d98c050e7225db1d8a18855f9 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 11:14:23 -0800 Subject: [PATCH 34/40] print final tui update before quitting --- src/cmd/cli/command/compose.go | 7 +++++++ src/pkg/cli/getServices.go | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 5c0ee9ed2..070476537 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -193,6 +193,13 @@ func monitorWithUI(ctx context.Context, project *compose.Project, provider clien } return nil }) + // empty out all of the service statuses before printing a final state + for _, name := range servicesNames { + p.Send(serviceUpdate{ + name: name, + status: "", + }) + } // Quit the UI when monitoring is done p.Quit() }() diff --git a/src/pkg/cli/getServices.go b/src/pkg/cli/getServices.go index dae51f65f..446ebc4d4 100644 --- a/src/pkg/cli/getServices.go +++ b/src/pkg/cli/getServices.go @@ -220,7 +220,8 @@ func PrintServiceStatesAndEndpoints(serviceEndpoints []ServiceEndpoint) error { return strings.Compare(a.Endpoint, b.Endpoint) }) - // remove "Service", "Deployment", and "State" columns if they are the same as the previous row + // to reduce noise, print empty "Service", "Deployment", and "State" columns + // if they are for the same service as the previous row lastService := "" for i := range serviceEndpoints { if serviceEndpoints[i].Service == lastService { From 921e53074127612d02e8f1c3fa83e88dc90ed182 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 11:43:29 -0800 Subject: [PATCH 35/40] move tui to cli pkg --- src/cmd/cli/command/compose.go | 178 +------------------------------ src/pkg/cli/composeUpTui.go | 186 +++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 177 deletions(-) create mode 100644 src/pkg/cli/composeUpTui.go diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 070476537..5c2bf8dce 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -6,9 +6,7 @@ import ( "fmt" "io" "slices" - "sort" "strings" - "sync" "time" "github.com/AlecAivazis/survey/v2" @@ -29,186 +27,12 @@ import ( "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) const DEFANG_PORTAL_HOST = "portal.defang.io" const SERVICE_PORTAL_URL = "https://" + DEFANG_PORTAL_HOST + "/service" -type deploymentModel struct { - services map[string]*serviceState - quitting bool - updateCh chan serviceUpdate -} - -type serviceState struct { - status string - spinner spinner.Model -} - -type serviceUpdate struct { - name string - status string -} - -var ( - spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#bc9724", Dark: "#2ddedc"}) - statusStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#a4729d", Dark: "#fae856"}) - nameStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#305897", Dark: "#cdd2c9"}) -) - -func newDeploymentModel(serviceNames []string) *deploymentModel { - services := make(map[string]*serviceState) - - for _, name := range serviceNames { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = spinnerStyle - - services[name] = &serviceState{ - status: "DEPLOYMENT_QUEUED", - spinner: s, - } - } - - return &deploymentModel{ - services: services, - updateCh: make(chan serviceUpdate, 100), - } -} - -func (m *deploymentModel) Init() tea.Cmd { - var cmds []tea.Cmd - for _, svc := range m.services { - cmds = append(cmds, svc.spinner.Tick) - } - return tea.Batch(cmds...) -} - -func (m *deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "ctrl+c" { - m.quitting = true - return m, tea.Quit - } - case serviceUpdate: - if svc, exists := m.services[msg.name]; exists { - svc.status = msg.status - } - return m, nil - case spinner.TickMsg: - var cmds []tea.Cmd - for _, svc := range m.services { - var cmd tea.Cmd - svc.spinner, cmd = svc.spinner.Update(msg) - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - } - return m, nil -} - -func (m *deploymentModel) View() string { - if m.quitting { - return "" - } - - var lines []string - // Sort services by name for consistent ordering - var serviceNames []string - for name := range m.services { - serviceNames = append(serviceNames, name) - } - sort.Strings(serviceNames) - - for _, name := range serviceNames { - svc := m.services[name] - - // Stop spinner for completed services - var spinnerOrCheck string - switch svc.status { - case "DEPLOYMENT_COMPLETED": - spinnerOrCheck = "✓ " - case "DEPLOYMENT_FAILED": - spinnerOrCheck = "✗ " - default: - spinnerOrCheck = svc.spinner.View() - } - - line := lipgloss.JoinHorizontal( - lipgloss.Left, - spinnerOrCheck, - " ", - nameStyle.Render("["+name+"]"), - " ", - statusStyle.Render(svc.status), - ) - lines = append(lines, line) - } - - return lipgloss.JoinVertical(lipgloss.Left, lines...) -} - -func monitorWithUI(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (map[string]defangv1.ServiceState, error) { - servicesNames := make([]string, 0, len(project.Services)) - for _, svc := range project.Services { - servicesNames = append(servicesNames, svc.Name) - } - - // Initialize the bubbletea model - model := newDeploymentModel(servicesNames) - - // Create the bubbletea program - p := tea.NewProgram(model) - - var ( - serviceStates map[string]defangv1.ServiceState - monitorErr error - wg sync.WaitGroup - ) - wg.Add(2) // One for UI, one for monitoring - - // Start the bubbletea UI in a goroutine - go func() { - defer wg.Done() - if _, err := p.Run(); err != nil { - // Handle UI errors if needed - } - }() - - // Start monitoring in a goroutine - go func() { - defer wg.Done() - serviceStates, monitorErr = cli.Monitor(ctx, project, provider, waitTimeout, deploymentID, func(msg *defangv1.SubscribeResponse, states *cli.ServiceStates) error { - // Send service status updates to the bubbletea model - for name, state := range *states { - p.Send(serviceUpdate{ - name: name, - status: state.String(), - }) - } - return nil - }) - // empty out all of the service statuses before printing a final state - for _, name := range servicesNames { - p.Send(serviceUpdate{ - name: name, - status: "", - }) - } - // Quit the UI when monitoring is done - p.Quit() - }() - - wg.Wait() - - return serviceStates, monitorErr -} - func printPlaygroundPortalServiceURLs(serviceInfos []*defangv1.ServiceInfo) { // We can only show services deployed to the prod1 defang SaaS environment. if global.Stack.Provider == client.ProviderDefang && global.Cluster == client.DefaultCluster { @@ -370,7 +194,7 @@ func makeComposeUpCmd() *cobra.Command { } } else { term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") - serviceStates, err = monitorWithUI(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) + serviceStates, err = cli.MonitorWithUI(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) } if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err diff --git a/src/pkg/cli/composeUpTui.go b/src/pkg/cli/composeUpTui.go new file mode 100644 index 000000000..f2f1702b1 --- /dev/null +++ b/src/pkg/cli/composeUpTui.go @@ -0,0 +1,186 @@ +package cli + +import ( + "context" + "sort" + "sync" + "time" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type deploymentModel struct { + services map[string]*serviceState + quitting bool + updateCh chan serviceUpdate +} + +type serviceState struct { + status string + spinner spinner.Model +} + +type serviceUpdate struct { + name string + status string +} + +var ( + spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#bc9724", Dark: "#2ddedc"}) + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#a4729d", Dark: "#fae856"}) + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#305897", Dark: "#cdd2c9"}) +) + +func newDeploymentModel(serviceNames []string) *deploymentModel { + services := make(map[string]*serviceState) + + for _, name := range serviceNames { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = spinnerStyle + + services[name] = &serviceState{ + status: "DEPLOYMENT_QUEUED", + spinner: s, + } + } + + return &deploymentModel{ + services: services, + updateCh: make(chan serviceUpdate, 100), + } +} + +func (m *deploymentModel) Init() tea.Cmd { + var cmds []tea.Cmd + for _, svc := range m.services { + cmds = append(cmds, svc.spinner.Tick) + } + return tea.Batch(cmds...) +} + +func (m *deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + case serviceUpdate: + if svc, exists := m.services[msg.name]; exists { + svc.status = msg.status + } + return m, nil + case spinner.TickMsg: + var cmds []tea.Cmd + for _, svc := range m.services { + var cmd tea.Cmd + svc.spinner, cmd = svc.spinner.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + return m, nil +} + +func (m *deploymentModel) View() string { + if m.quitting { + return "" + } + + var lines []string + // Sort services by name for consistent ordering + var serviceNames []string + for name := range m.services { + serviceNames = append(serviceNames, name) + } + sort.Strings(serviceNames) + + for _, name := range serviceNames { + svc := m.services[name] + + // Stop spinner for completed services + var spinnerOrCheck string + switch svc.status { + case "DEPLOYMENT_COMPLETED": + spinnerOrCheck = "✓ " + case "DEPLOYMENT_FAILED": + spinnerOrCheck = "✗ " + default: + spinnerOrCheck = svc.spinner.View() + } + + line := lipgloss.JoinHorizontal( + lipgloss.Left, + spinnerOrCheck, + " ", + nameStyle.Render("["+name+"]"), + " ", + statusStyle.Render(svc.status), + ) + lines = append(lines, line) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func MonitorWithUI(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (map[string]defangv1.ServiceState, error) { + servicesNames := make([]string, 0, len(project.Services)) + for _, svc := range project.Services { + servicesNames = append(servicesNames, svc.Name) + } + + // Initialize the bubbletea model + model := newDeploymentModel(servicesNames) + + // Create the bubbletea program + p := tea.NewProgram(model) + + var ( + serviceStates map[string]defangv1.ServiceState + monitorErr error + wg sync.WaitGroup + ) + wg.Add(2) // One for UI, one for monitoring + + // Start the bubbletea UI in a goroutine + go func() { + defer wg.Done() + if _, err := p.Run(); err != nil { + // Handle UI errors if needed + } + }() + + // Start monitoring in a goroutine + go func() { + defer wg.Done() + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, deploymentID, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { + // Send service status updates to the bubbletea model + for name, state := range *states { + p.Send(serviceUpdate{ + name: name, + status: state.String(), + }) + } + return nil + }) + // empty out all of the service statuses before printing a final state + for _, name := range servicesNames { + p.Send(serviceUpdate{ + name: name, + status: "", + }) + } + // Quit the UI when monitoring is done + p.Quit() + }() + + wg.Wait() + + return serviceStates, monitorErr +} From 7b53dbc16f7a426ccaa97d0bd73af124dbcdda02 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 12:03:41 -0800 Subject: [PATCH 36/40] update all services states together --- src/pkg/cli/composeUpTui.go | 50 ++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/pkg/cli/composeUpTui.go b/src/pkg/cli/composeUpTui.go index f2f1702b1..744c9cfe0 100644 --- a/src/pkg/cli/composeUpTui.go +++ b/src/pkg/cli/composeUpTui.go @@ -21,13 +21,12 @@ type deploymentModel struct { } type serviceState struct { - status string + status defangv1.ServiceState spinner spinner.Model } type serviceUpdate struct { - name string - status string + services map[string]defangv1.ServiceState } var ( @@ -45,7 +44,7 @@ func newDeploymentModel(serviceNames []string) *deploymentModel { s.Style = spinnerStyle services[name] = &serviceState{ - status: "DEPLOYMENT_QUEUED", + status: defangv1.ServiceState_DEPLOYMENT_PENDING, spinner: s, } } @@ -72,8 +71,8 @@ func (m *deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } case serviceUpdate: - if svc, exists := m.services[msg.name]; exists { - svc.status = msg.status + for name, status := range msg.services { + m.services[name].status = status } return m, nil case spinner.TickMsg: @@ -107,21 +106,29 @@ func (m *deploymentModel) View() string { // Stop spinner for completed services var spinnerOrCheck string switch svc.status { - case "DEPLOYMENT_COMPLETED": + case defangv1.ServiceState_DEPLOYMENT_COMPLETED: spinnerOrCheck = "✓ " - case "DEPLOYMENT_FAILED": + case defangv1.ServiceState_DEPLOYMENT_FAILED: spinnerOrCheck = "✗ " default: spinnerOrCheck = svc.spinner.View() } + statusText := svc.status.String() + switch svc.status { + case defangv1.ServiceState_NOT_SPECIFIED: + statusText = "" + case defangv1.ServiceState_DEPLOYMENT_PENDING: + statusText = "DEPLOYING" + } + line := lipgloss.JoinHorizontal( lipgloss.Left, spinnerOrCheck, " ", nameStyle.Render("["+name+"]"), " ", - statusStyle.Render(svc.status), + statusStyle.Render(statusText), ) lines = append(lines, line) } @@ -161,21 +168,24 @@ func MonitorWithUI(ctx context.Context, project *compose.Project, provider clien defer wg.Done() serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, deploymentID, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { // Send service status updates to the bubbletea model - for name, state := range *states { - p.Send(serviceUpdate{ - name: name, - status: state.String(), - }) + services := make(map[string]defangv1.ServiceState) + for _, name := range servicesNames { + state, ok := (*states)[name] + if ok { + services[name] = state + } else { + services[name] = defangv1.ServiceState_DEPLOYMENT_PENDING + } } + p.Send(serviceUpdate{ + services: services, + }) return nil }) // empty out all of the service statuses before printing a final state - for _, name := range servicesNames { - p.Send(serviceUpdate{ - name: name, - status: "", - }) - } + p.Send(serviceUpdate{ + services: make(map[string]defangv1.ServiceState), + }) // Quit the UI when monitoring is done p.Quit() }() From ced032e63aa2ce7c13f8db36f7fc0898459008da Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 12:06:23 -0800 Subject: [PATCH 37/40] Revert "update all services states together" This reverts commit 7b53dbc16f7a426ccaa97d0bd73af124dbcdda02. --- src/pkg/cli/composeUpTui.go | 50 +++++++++++++++---------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/src/pkg/cli/composeUpTui.go b/src/pkg/cli/composeUpTui.go index 744c9cfe0..f2f1702b1 100644 --- a/src/pkg/cli/composeUpTui.go +++ b/src/pkg/cli/composeUpTui.go @@ -21,12 +21,13 @@ type deploymentModel struct { } type serviceState struct { - status defangv1.ServiceState + status string spinner spinner.Model } type serviceUpdate struct { - services map[string]defangv1.ServiceState + name string + status string } var ( @@ -44,7 +45,7 @@ func newDeploymentModel(serviceNames []string) *deploymentModel { s.Style = spinnerStyle services[name] = &serviceState{ - status: defangv1.ServiceState_DEPLOYMENT_PENDING, + status: "DEPLOYMENT_QUEUED", spinner: s, } } @@ -71,8 +72,8 @@ func (m *deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } case serviceUpdate: - for name, status := range msg.services { - m.services[name].status = status + if svc, exists := m.services[msg.name]; exists { + svc.status = msg.status } return m, nil case spinner.TickMsg: @@ -106,29 +107,21 @@ func (m *deploymentModel) View() string { // Stop spinner for completed services var spinnerOrCheck string switch svc.status { - case defangv1.ServiceState_DEPLOYMENT_COMPLETED: + case "DEPLOYMENT_COMPLETED": spinnerOrCheck = "✓ " - case defangv1.ServiceState_DEPLOYMENT_FAILED: + case "DEPLOYMENT_FAILED": spinnerOrCheck = "✗ " default: spinnerOrCheck = svc.spinner.View() } - statusText := svc.status.String() - switch svc.status { - case defangv1.ServiceState_NOT_SPECIFIED: - statusText = "" - case defangv1.ServiceState_DEPLOYMENT_PENDING: - statusText = "DEPLOYING" - } - line := lipgloss.JoinHorizontal( lipgloss.Left, spinnerOrCheck, " ", nameStyle.Render("["+name+"]"), " ", - statusStyle.Render(statusText), + statusStyle.Render(svc.status), ) lines = append(lines, line) } @@ -168,24 +161,21 @@ func MonitorWithUI(ctx context.Context, project *compose.Project, provider clien defer wg.Done() serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, deploymentID, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { // Send service status updates to the bubbletea model - services := make(map[string]defangv1.ServiceState) - for _, name := range servicesNames { - state, ok := (*states)[name] - if ok { - services[name] = state - } else { - services[name] = defangv1.ServiceState_DEPLOYMENT_PENDING - } + for name, state := range *states { + p.Send(serviceUpdate{ + name: name, + status: state.String(), + }) } - p.Send(serviceUpdate{ - services: services, - }) return nil }) // empty out all of the service statuses before printing a final state - p.Send(serviceUpdate{ - services: make(map[string]defangv1.ServiceState), - }) + for _, name := range servicesNames { + p.Send(serviceUpdate{ + name: name, + status: "", + }) + } // Quit the UI when monitoring is done p.Quit() }() From 9286b7b6ac6d631536c510332d13ecb0616152a9 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 12:10:54 -0800 Subject: [PATCH 38/40] use typed service states --- src/pkg/cli/composeUpTui.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/pkg/cli/composeUpTui.go b/src/pkg/cli/composeUpTui.go index f2f1702b1..bfa44bc96 100644 --- a/src/pkg/cli/composeUpTui.go +++ b/src/pkg/cli/composeUpTui.go @@ -21,13 +21,13 @@ type deploymentModel struct { } type serviceState struct { - status string + status defangv1.ServiceState spinner spinner.Model } type serviceUpdate struct { name string - status string + status defangv1.ServiceState } var ( @@ -45,7 +45,7 @@ func newDeploymentModel(serviceNames []string) *deploymentModel { s.Style = spinnerStyle services[name] = &serviceState{ - status: "DEPLOYMENT_QUEUED", + status: defangv1.ServiceState_DEPLOYMENT_PENDING, spinner: s, } } @@ -107,21 +107,31 @@ func (m *deploymentModel) View() string { // Stop spinner for completed services var spinnerOrCheck string switch svc.status { - case "DEPLOYMENT_COMPLETED": + case defangv1.ServiceState_DEPLOYMENT_COMPLETED: spinnerOrCheck = "✓ " - case "DEPLOYMENT_FAILED": + case defangv1.ServiceState_DEPLOYMENT_FAILED: spinnerOrCheck = "✗ " default: spinnerOrCheck = svc.spinner.View() } + var statusText string + switch svc.status { + case defangv1.ServiceState_NOT_SPECIFIED: + statusText = "" + case defangv1.ServiceState_DEPLOYMENT_PENDING: + statusText = "DEPLOYING" + default: + statusText = svc.status.String() + } + line := lipgloss.JoinHorizontal( lipgloss.Left, spinnerOrCheck, " ", nameStyle.Render("["+name+"]"), " ", - statusStyle.Render(svc.status), + statusStyle.Render(statusText), ) lines = append(lines, line) } @@ -164,7 +174,7 @@ func MonitorWithUI(ctx context.Context, project *compose.Project, provider clien for name, state := range *states { p.Send(serviceUpdate{ name: name, - status: state.String(), + status: state, }) } return nil @@ -173,7 +183,7 @@ func MonitorWithUI(ctx context.Context, project *compose.Project, provider clien for _, name := range servicesNames { p.Send(serviceUpdate{ name: name, - status: "", + status: defangv1.ServiceState_NOT_SPECIFIED, }) } // Quit the UI when monitoring is done From 07e4a614a6b128bb4af6d00b8b314cf8c13376f8 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Mon, 19 Jan 2026 12:11:22 -0800 Subject: [PATCH 39/40] assume pending deployments have completed when monitoring completes successfully --- src/pkg/cli/composeUpTui.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/pkg/cli/composeUpTui.go b/src/pkg/cli/composeUpTui.go index bfa44bc96..e62acad08 100644 --- a/src/pkg/cli/composeUpTui.go +++ b/src/pkg/cli/composeUpTui.go @@ -179,12 +179,17 @@ func MonitorWithUI(ctx context.Context, project *compose.Project, provider clien } return nil }) - // empty out all of the service statuses before printing a final state - for _, name := range servicesNames { - p.Send(serviceUpdate{ - name: name, - status: defangv1.ServiceState_NOT_SPECIFIED, - }) + + if monitorErr == nil { + // prevent leaving partial state and spinners on screen after successful completion + for _, serviceName := range servicesNames { + if serviceStates[serviceName] == defangv1.ServiceState_DEPLOYMENT_PENDING { + p.Send(serviceUpdate{ + name: serviceName, + status: defangv1.ServiceState_DEPLOYMENT_COMPLETED, + }) + } + } } // Quit the UI when monitoring is done p.Quit() From f4a01a6b8f31e0333bd4fbfe336baa0eb12f829e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lio=E6=9D=8E=E6=AD=90?= Date: Mon, 19 Jan 2026 20:28:41 -0800 Subject: [PATCH 40/40] Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/pkg/cli/getServices_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/cli/getServices_test.go b/src/pkg/cli/getServices_test.go index 5462cfd6e..203755538 100644 --- a/src/pkg/cli/getServices_test.go +++ b/src/pkg/cli/getServices_test.go @@ -147,7 +147,7 @@ foo a1b2c3 NOT_SPECIFIED unhealthy (404 Not Found) https://test-foo- }) } -func ServiceEndpointFromServiceInfo(t *testing.T) { +func TestServiceEndpointFromServiceInfo(t *testing.T) { tests := []struct { name string serviceinfo *defangv1.ServiceInfo