feat: improved deploy progress reporting

See toolshed/abra#478
This commit is contained in:
2025-03-20 14:23:09 +01:00
committed by decentral1se
parent d0f982456e
commit 47045ca8f1
85 changed files with 8828 additions and 360 deletions

View File

@ -3,20 +3,20 @@ package stack // https://github.com/docker/cli/blob/master/cli/command/stack/swa
import (
"context"
"fmt"
"io"
"io/ioutil"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"time"
stdlibErr "errors"
tea "github.com/charmbracelet/bubbletea"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/ui"
"coopcloud.tech/abra/pkg/upstream/convert"
"github.com/docker/cli/cli/command/service/progress"
"github.com/docker/cli/cli/command/stack/formatter"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types"
@ -177,7 +177,7 @@ func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string)
func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace convert.Namespace, services map[string]struct{}) {
oldServices, err := GetStackServices(ctx, cl, namespace.Name())
if err != nil {
log.Infof("failed to list services: %s", err)
log.Warnf("failed to list services: %s", err)
}
pruneServices := []swarm.Service{}
@ -191,7 +191,17 @@ func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace conve
}
// RunDeploy is the swarm implementation of docker stack deploy
func RunDeploy(cl *dockerClient.Client, opts Deploy, cfg *composetypes.Config, appName string, dontWait bool) error {
func RunDeploy(
cl *dockerClient.Client,
opts Deploy,
cfg *composetypes.Config,
appName string,
serverName string,
dontWait bool,
filters filters.Args,
) error {
log.Info("initialising deployment")
if err := validateResolveImageFlag(&opts); err != nil {
return err
}
@ -201,7 +211,16 @@ func RunDeploy(cl *dockerClient.Client, opts Deploy, cfg *composetypes.Config, a
opts.ResolveImage = ResolveImageNever
}
return deployCompose(context.Background(), cl, opts, cfg, appName, dontWait)
return deployCompose(
context.Background(),
cl,
opts,
cfg,
appName,
serverName,
dontWait,
filters,
)
}
// validateResolveImageFlag validates the opts.resolveImage command line option
@ -214,7 +233,16 @@ func validateResolveImageFlag(opts *Deploy) error {
}
}
func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, config *composetypes.Config, appName string, dontWait bool) error {
func deployCompose(
ctx context.Context,
cl *dockerClient.Client,
opts Deploy,
config *composetypes.Config,
appName string,
serverName string,
dontWait bool,
filters filters.Args,
) error {
namespace := convert.NewNamespace(opts.Namespace)
if opts.Prune {
@ -255,7 +283,14 @@ func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, co
return err
}
serviceIDs, err := deployServices(ctx, cl, services, namespace, opts.SendRegistryAuth, opts.ResolveImage)
serviceIDs, err := deployServices(
ctx,
cl,
services,
namespace,
opts.SendRegistryAuth,
opts.ResolveImage,
)
if err != nil {
return err
}
@ -265,13 +300,16 @@ func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, co
return nil
}
log.Infof("waiting for %s to deploy... please hold 🤚", appName)
if err := waitOnServices(ctx, cl, serviceIDs, appName); err != nil {
return err
waitOpts := WaitOpts{
Services: serviceIDs,
AppName: appName,
ServerName: serverName,
Filters: filters,
}
log.Infof("successfully deployed %s", appName)
if err := WaitOnServices(ctx, cl, waitOpts); err != nil {
return err
}
return nil
}
@ -343,7 +381,7 @@ func createConfigs(ctx context.Context, cl *dockerClient.Client, configs []swarm
}
case dockerClient.IsErrNotFound(err):
// config does not exist, then we create a new one.
log.Infof("creating config %s", configSpec.Name)
log.Debugf("creating config %s", configSpec.Name)
if _, err := cl.ConfigCreate(ctx, configSpec); err != nil {
return errors.Wrapf(err, "failed to create config %s", configSpec.Name)
}
@ -374,7 +412,7 @@ func createNetworks(ctx context.Context, cl *dockerClient.Client, namespace conv
createOpts.Driver = defaultNetworkDriver
}
log.Infof("creating network %s", name)
log.Debugf("creating network %s", name)
if _, err := cl.NetworkCreate(ctx, name, createOpts); err != nil {
return errors.Wrapf(err, "failed to create network %s", name)
}
@ -388,10 +426,12 @@ func deployServices(
services map[string]swarm.ServiceSpec,
namespace convert.Namespace,
sendAuth bool,
resolveImage string) ([]string, error) {
resolveImage string) ([]ui.ServiceMeta, error) {
var servicesMeta []ui.ServiceMeta
existingServices, err := GetStackServices(ctx, cl, namespace.Name())
if err != nil {
return nil, err
return servicesMeta, err
}
existingServiceMap := make(map[string]swarm.Service)
@ -399,8 +439,6 @@ func deployServices(
existingServiceMap[service.Spec.Name] = service
}
var serviceIDs []string
for internalName, serviceSpec := range services {
var (
name = namespace.Scope(internalName)
@ -409,7 +447,7 @@ func deployServices(
)
if service, exists := existingServiceMap[name]; exists {
log.Infof("updating %s", name)
log.Debugf("updating %s", name)
updateOpts := types.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth}
@ -451,9 +489,12 @@ func deployServices(
log.Warn(warning)
}
serviceIDs = append(serviceIDs, service.ID)
servicesMeta = append(servicesMeta, ui.ServiceMeta{
Name: name,
ID: service.ID,
})
} else {
log.Infof("creating %s", name)
log.Debugf("creating %s", name)
createOpts := types.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth}
@ -467,11 +508,14 @@ func deployServices(
return nil, errors.Wrapf(err, "failed to create %s", name)
}
serviceIDs = append(serviceIDs, serviceCreateResponse.ID)
servicesMeta = append(servicesMeta, ui.ServiceMeta{
Name: name,
ID: serviceCreateResponse.ID,
})
}
}
return serviceIDs, nil
return servicesMeta, nil
}
func getStackNetworks(ctx context.Context, dockerclient client.APIClient, namespace string) ([]networktypes.Inspect, error) {
@ -486,67 +530,89 @@ func getStackConfigs(ctx context.Context, dockerclient client.APIClient, namespa
return dockerclient.ConfigList(ctx, types.ConfigListOptions{Filters: getStackFilter(namespace)})
}
func waitOnServices(ctx context.Context, cl *dockerClient.Client, serviceIDs []string, appName string) error {
var errs []error
func timestamp() string {
ts := time.Now().UTC().Format(time.RFC3339)
return strings.Replace(ts, ":", "", -1) // get rid of offensive colons
}
for _, serviceID := range serviceIDs {
if err := WaitOnService(ctx, cl, serviceID, appName); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", serviceID, err))
}
type WaitOpts struct {
AppName string
Filters filters.Args
NoLog bool
Quiet bool
ServerName string
Services []ui.ServiceMeta
}
func WaitOnServices(ctx context.Context, cl *dockerClient.Client, opts WaitOpts) error {
timeout := time.Duration(WaitTimeout) * time.Second
model := ui.DeployInitialModel(ctx, cl, opts.Services, opts.AppName, timeout, opts.Filters)
tui := tea.NewProgram(model)
if !opts.Quiet {
log.Info("polling deployment status")
}
if len(errs) > 0 {
m, err := log.Without(
func() (tea.Model, error) {
return tui.Run()
},
)
if err != nil {
return fmt.Errorf("waitOnServices: error running TUI: %s", err)
}
deployModel := m.(ui.Model)
if deployModel.TimedOut || deployModel.Failed || deployModel.Quit {
var errs []error
if deployModel.Failed {
errs = append(errs, fmt.Errorf("deploy failed 🛑"))
} else if deployModel.TimedOut {
errs = append(errs, fmt.Errorf("deploy timed out 🟠"))
} else {
errs = append(errs, fmt.Errorf("deploy in progress 🟠"))
}
for _, s := range *deployModel.Streams {
if s.Err != nil {
errs = append(errs, fmt.Errorf("%s: %s", s.Name, s.Err))
}
}
if len(*deployModel.Logs) > 0 && !opts.NoLog {
logsPath := filepath.Join(
config.LOGS_DIR,
opts.ServerName,
fmt.Sprintf("%s_%s", opts.AppName, timestamp()),
)
if err := os.MkdirAll(filepath.Join(config.LOGS_DIR, opts.ServerName), 0764); err != nil {
return fmt.Errorf("waitOnServices: error creating log dir: %s", err)
}
file, err := os.Create(logsPath)
if err != nil {
return fmt.Errorf("waitOnServices: error opening file: %s", err)
}
defer file.Close()
s := strings.Join(*deployModel.Logs, "\n")
if _, err := file.WriteString(s); err != nil {
return fmt.Errorf("waitOnServices: writeFile: %s", err)
}
errs = append(errs, fmt.Errorf("logs: %s", logsPath))
}
return stdlibErr.Join(errs...)
}
return nil
}
// https://github.com/docker/cli/blob/master/cli/command/service/helpers.go
// https://github.com/docker/cli/blob/master/cli/command/service/progress/progress.go
func WaitOnService(ctx context.Context, cl *dockerClient.Client, serviceID, appName string) error {
errChan := make(chan error, 1)
pipeReader, pipeWriter := io.Pipe()
sigintChannel := make(chan os.Signal, 1)
signal.Notify(sigintChannel, os.Interrupt)
defer signal.Stop(sigintChannel)
go func() {
errChan <- progress.ServiceProgress(ctx, cl, serviceID, pipeWriter)
}()
go io.Copy(ioutil.Discard, pipeReader)
timeout := time.Duration(WaitTimeout) * time.Second
select {
case err := <-errChan:
return err
case <-sigintChannel:
return fmt.Errorf(`
Not waiting for %s to deploy. The deployment is ongoing...
If you want to stop the deployment, try:
abra app undeploy %s`, appName, appName)
case <-time.After(timeout):
return fmt.Errorf(`
%s has not converged (%s second timeout reached).
This does not necessarily mean your deployment has failed, it may just be that
the app is taking longer to deploy based on your server resources or network
latency.
You can track latest deployment status with:
abra app ps %s
And inspect the logs with:
abra app logs %s
`, appName, timeout, appName, appName)
if !opts.Quiet {
log.Info("deploy succeeded 🟢")
}
return nil
}
// Copypasta from https://github.com/docker/cli/blob/master/cli/command/stack/swarm/list.go