diff --git a/cli/app/deploy.go b/cli/app/deploy.go index 19cd8872..754865f4 100644 --- a/cli/app/deploy.go +++ b/cli/app/deploy.go @@ -200,8 +200,6 @@ checkout as-is. Recipe commit hashes are also supported as values for log.Fatal(err) } - log.Debug(i18n.G("set waiting timeout to %d second(s)", stack.WaitTimeout)) - serviceNames, err := appPkg.GetAppServiceNames(app.Name) if err != nil { log.Fatal(err) diff --git a/cli/app/rollback.go b/cli/app/rollback.go index 0218dd0a..1d49aaec 100644 --- a/cli/app/rollback.go +++ b/cli/app/rollback.go @@ -199,8 +199,6 @@ beforehand. See "abra app backup" for more.`), log.Fatal(err) } - log.Debug(i18n.G("set waiting timeout to %d second(s)", stack.WaitTimeout)) - serviceNames, err := appPkg.GetAppServiceNames(app.Name) if err != nil { log.Fatal(err) diff --git a/cli/app/undeploy.go b/cli/app/undeploy.go index ecb32365..3f5124dd 100644 --- a/cli/app/undeploy.go +++ b/cli/app/undeploy.go @@ -85,8 +85,6 @@ Passing "--prune/-p" does not remove those volumes.`), log.Fatal(err) } - log.Info(i18n.G("initialising undeploy")) - rmOpts := stack.Remove{ Namespaces: []string{stackName}, Detach: false, diff --git a/cli/app/upgrade.go b/cli/app/upgrade.go index be574c45..489030d6 100644 --- a/cli/app/upgrade.go +++ b/cli/app/upgrade.go @@ -237,8 +237,6 @@ beforehand. See "abra app backup" for more.`), log.Fatal(err) } - log.Debug(i18n.G("set waiting timeout to %d second(s)", stack.WaitTimeout)) - serviceNames, err := appPkg.GetAppServiceNames(app.Name) if err != nil { log.Fatal(err) diff --git a/pkg/app/compose.go b/pkg/app/compose.go index dd5070b1..781d5877 100644 --- a/pkg/app/compose.go +++ b/pkg/app/compose.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "strconv" @@ -87,13 +88,21 @@ func GetLabel(compose *composetypes.Config, stackName string, label string) stri return "" } -// GetTimeoutFromLabel reads the timeout value from docker label "coop-cloud.${STACK_NAME}.TIMEOUT" and returns 50 as default value +// GetTimeoutFromLabel reads the timeout value from docker label +// `coop-cloud.${STACK_NAME}.timeout=...` if present. A value is present if the +// operator uses a `TIMEOUT=...` in their app env. func GetTimeoutFromLabel(compose *composetypes.Config, stackName string) (int, error) { - timeout := 50 // Default Timeout - var err error = nil + var timeout int + if timeoutLabel := GetLabel(compose, stackName, "timeout"); timeoutLabel != "" { log.Debug(i18n.G("timeout label: %s", timeoutLabel)) + + var err error timeout, err = strconv.Atoi(timeoutLabel) + if err != nil { + return timeout, errors.New(i18n.G("unable to convert timeout label %s to int: %s", timeoutLabel, err)) + } } - return timeout, err + + return timeout, nil } diff --git a/pkg/envfile/envfile_test.go b/pkg/envfile/envfile_test.go index f669ce90..9ae75da6 100644 --- a/pkg/envfile/envfile_test.go +++ b/pkg/envfile/envfile_test.go @@ -15,7 +15,7 @@ import ( ) func TestGetAllFoldersInDirectory(t *testing.T) { - folders, err := config.GetAllFoldersInDirectory(testPkg.TestFolder) + folders, err := config.GetAllFoldersInDirectory(testPkg.TestDir) if err != nil { t.Fatal(err) } @@ -25,7 +25,7 @@ func TestGetAllFoldersInDirectory(t *testing.T) { } func TestGetAllFilesInDirectory(t *testing.T) { - files, err := config.GetAllFilesInDirectory(testPkg.TestFolder) + files, err := config.GetAllFilesInDirectory(testPkg.TestDir) if err != nil { t.Fatal(err) } diff --git a/pkg/ui/deploy.go b/pkg/ui/deploy.go index 20a8c87a..92bb41ce 100644 --- a/pkg/ui/deploy.go +++ b/pkg/ui/deploy.go @@ -197,7 +197,10 @@ func (m Model) Init() tea.Cmd { ) } - cmds = append(cmds, func() tea.Msg { return deployTimeout(m) }) + if m.timeout != 0 { + cmds = append(cmds, func() tea.Msg { return deployTimeout(m) }) + } + cmds = append(cmds, func() tea.Msg { return m.gatherLogs() }) return tea.Batch(cmds...) diff --git a/pkg/upstream/stack/loader.go b/pkg/upstream/stack/loader.go index ab0e609e..1910129a 100644 --- a/pkg/upstream/stack/loader.go +++ b/pkg/upstream/stack/loader.go @@ -7,10 +7,12 @@ import ( "sort" "strings" + "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" "github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/schema" composetypes "github.com/docker/cli/cli/compose/types" + "github.com/pkg/errors" ) // DontSkipValidation ensures validation is done for compose file loading @@ -38,8 +40,7 @@ func LoadComposefile(opts Deploy, appEnv map[string]string, options ...func(*loa config, err := loader.Load(configDetails, options...) if err != nil { if fpe, ok := err.(*loader.ForbiddenPropertiesError); ok { - return nil, fmt.Errorf("compose file contains unsupported options: %s", - propertyWarnings(fpe.Properties)) + return nil, errors.New(i18n.G("compose file contains unsupported options: %s", propertyWarnings(fpe.Properties))) } return nil, err } @@ -51,14 +52,12 @@ func LoadComposefile(opts Deploy, appEnv map[string]string, options ...func(*loa unsupportedProperties := loader.GetUnsupportedProperties(dicts...) if len(unsupportedProperties) > 0 { - log.Warnf("%s: ignoring unsupported options: %s", - recipeName, strings.Join(unsupportedProperties, ", ")) + log.Warn(i18n.G("%s: ignoring unsupported options: %s", recipeName, strings.Join(unsupportedProperties, ", "))) } deprecatedProperties := loader.GetDeprecatedProperties(dicts...) if len(deprecatedProperties) > 0 { - log.Warnf("%s: ignoring deprecated options: %s", - recipeName, propertyWarnings(deprecatedProperties)) + log.Warn(i18n.G("%s: ignoring deprecated options: %s", recipeName, propertyWarnings(deprecatedProperties))) } return config, nil } @@ -106,7 +105,7 @@ func buildEnvironment(env []string) (map[string]string, error) { for _, s := range env { // if value is empty, s is like "K=", not "K". if !strings.Contains(s, "=") { - return result, fmt.Errorf("unexpected environment %q", s) + return result, errors.New(i18n.G("unexpected environment %q", s)) } kv := strings.SplitN(s, "=", 2) result[kv[0]] = kv[1] diff --git a/pkg/upstream/stack/remove.go b/pkg/upstream/stack/remove.go index a2fde29d..21825e30 100644 --- a/pkg/upstream/stack/remove.go +++ b/pkg/upstream/stack/remove.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/network" @@ -21,6 +22,12 @@ import ( // RunRemove is the swarm implementation of docker stack remove func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error { + log.Info(i18n.G("initialising undeploy")) + + if WaitTimeout != 0 { + log.Debug(i18n.G("timeout: set to %d second(s)", WaitTimeout)) + } + sigIntCh := make(chan os.Signal, 1) signal.Notify(sigIntCh, os.Interrupt) defer signal.Stop(sigIntCh) @@ -62,7 +69,7 @@ func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error } if len(services)+len(networks)+len(secrets)+len(configs) == 0 { - log.Warnf("nothing found in stack: %s", namespace) + log.Warn(i18n.G("nothing found in stack: %s", namespace)) continue } @@ -72,17 +79,17 @@ func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error hasError = removeNetworks(ctx, client, networks) || hasError if hasError { - errs = append(errs, fmt.Sprintf("failed to remove some resources from stack: %s", namespace)) + errs = append(errs, fmt.Sprint(i18n.G("failed to remove some resources from stack: %s", namespace))) continue } - log.Info("polling undeploy status") + log.Info(i18n.G("polling undeploy status")) timeout, err := waitOnTasks(ctx, client, namespace) if timeout { errs = append(errs, err.Error()) } else { if err != nil { - errs = append(errs, fmt.Sprintf("failed to wait on tasks of stack: %s: %s", namespace, err)) + errs = append(errs, fmt.Sprint(i18n.G("failed to wait on tasks of stack: %s: %s", namespace, err))) } } } @@ -99,7 +106,7 @@ func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error case <-waitCh: return nil case <-sigIntCh: - return fmt.Errorf("skipping as requested, undeploy still in progress 🟠") + return errors.New(i18n.G("skipping as requested, undeploy still in progress 🟠")) case err := <-errCh: return err } @@ -121,10 +128,10 @@ func removeServices( var hasError bool sort.Slice(services, sortServiceByName(services)) for _, service := range services { - log.Debugf("removing service %s", service.Spec.Name) + log.Debug(i18n.G("removing service %s", service.Spec.Name)) if err := client.ServiceRemove(ctx, service.ID); err != nil { hasError = true - log.Fatalf("failed to remove service %s: %s", service.ID, err) + log.Fatal(i18n.G("failed to remove service %s: %s", service.ID, err)) } } return hasError @@ -137,10 +144,10 @@ func removeNetworks( ) bool { var hasError bool for _, network := range networks { - log.Debugf("removing network %s", network.Name) + log.Debug(i18n.G("removing network %s", network.Name)) if err := client.NetworkRemove(ctx, network.ID); err != nil { hasError = true - log.Fatalf("failed to remove network %s: %s", network.ID, err) + log.Fatal(i18n.G("failed to remove network %s: %s", network.ID, err)) } } return hasError @@ -153,10 +160,10 @@ func removeSecrets( ) bool { var hasError bool for _, secret := range secrets { - log.Debugf("removing secret %s", secret.Spec.Name) + log.Debug(i18n.G("removing secret %s", secret.Spec.Name)) if err := client.SecretRemove(ctx, secret.ID); err != nil { hasError = true - log.Fatalf("Failed to remove secret %s: %s", secret.ID, err) + log.Fatal(i18n.G("failed to remove secret %s: %s", secret.ID, err)) } } return hasError @@ -169,10 +176,10 @@ func removeConfigs( ) bool { var hasError bool for _, config := range configs { - log.Debugf("removing config %s", config.Spec.Name) + log.Debug(i18n.G("removing config %s", config.Spec.Name)) if err := client.ConfigRemove(ctx, config.ID); err != nil { hasError = true - log.Fatalf("failed to remove config %s: %s", config.ID, err) + log.Fatal(i18n.G("failed to remove config %s: %s", config.ID, err)) } } return hasError @@ -206,12 +213,17 @@ func terminalState(state swarm.TaskState) bool { func waitOnTasks(ctx context.Context, client apiclient.APIClient, namespace string) (bool, error) { var timedOut bool - log.Debugf("waiting on undeploy tasks (timeout=%v secs)", WaitTimeout) - go func() { - t := time.Duration(WaitTimeout) * time.Second - <-time.After(t) - log.Debug("timed out on undeploy") + if WaitTimeout == 0 { + return + } + + log.Debug(i18n.G("timeout: waiting on undeploy tasks (timeout=%v secs)", WaitTimeout)) + + timeout := time.Duration(WaitTimeout) * time.Second + <-time.After(timeout) + + log.Debug(i18n.G("timed out on undeploy (timeout=%v sec)", WaitTimeout)) timedOut = true }() @@ -219,7 +231,7 @@ func waitOnTasks(ctx context.Context, client apiclient.APIClient, namespace stri for { tasks, err := getStackTasks(ctx, client, namespace) if err != nil { - return false, fmt.Errorf("failed to get tasks: %w", err) + return false, errors.New(i18n.G("failed to get tasks: %w", err)) } for _, task := range tasks { @@ -234,7 +246,7 @@ func waitOnTasks(ctx context.Context, client apiclient.APIClient, namespace stri } if timedOut { - return true, fmt.Errorf("deployment timed out 🟠") + return true, errors.New(i18n.G("deployment timed out 🟠")) } } diff --git a/pkg/upstream/stack/stack.go b/pkg/upstream/stack/stack.go index b2bf1230..628ee414 100644 --- a/pkg/upstream/stack/stack.go +++ b/pkg/upstream/stack/stack.go @@ -14,6 +14,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "coopcloud.tech/abra/pkg/config" + "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/ui" "coopcloud.tech/abra/pkg/upstream/convert" @@ -39,8 +40,9 @@ const ( ResolveImageNever = "never" ) -// Timeout to wait until docker services converge, default is 50s (random choice) -var WaitTimeout = 50 +// Timeout to wait until docker services converge. This timeout is disabled by +// default but can be configured by passing a TIMEOUT=... in the app .env +var WaitTimeout = 0 type StackStatus struct { Services []swarm.Service @@ -152,7 +154,7 @@ func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string) if isChaos, ok := service.Spec.Labels[labelKey]; ok { boolVal, err := strconv.ParseBool(isChaos) if err != nil { - return deployMeta, fmt.Errorf("unable to parse '%s' value as bool: %s", labelKey, err) + return deployMeta, errors.New(i18n.G("unable to parse '%s' value as bool: %s", labelKey, err)) } deployMeta.IsChaos = boolVal } @@ -164,12 +166,12 @@ func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string) } } - log.Debugf("%s has been detected as deployed: %v", stackName, deployMeta) + log.Debug(i18n.G("%s has been detected as deployed: %v", stackName, deployMeta)) return deployMeta, nil } - log.Debugf("%s has been detected as not deployed", stackName) + log.Debug(i18n.G("%s has been detected as not deployed", stackName)) return deployMeta, nil } @@ -178,7 +180,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.Warnf("failed to list services: %s", err) + log.Warn(i18n.G("failed to list services: %s", err)) } pruneServices := []swarm.Service{} @@ -201,7 +203,11 @@ func RunDeploy( dontWait bool, filters filters.Args, ) error { - log.Info("initialising deployment") + log.Info(i18n.G("initialising deployment")) + + if WaitTimeout != 0 { + log.Debug(i18n.G("timeout: set to %d second(s)", WaitTimeout)) + } if err := validateResolveImageFlag(&opts); err != nil { return err @@ -230,7 +236,7 @@ func validateResolveImageFlag(opts *Deploy) error { case ResolveImageAlways, ResolveImageChanged, ResolveImageNever: return nil default: - return errors.Errorf("invalid option %s for flag --resolve-image", opts.ResolveImage) + return errors.New(i18n.G("invalid option %s for flag --resolve-image", opts.ResolveImage)) } } @@ -297,7 +303,7 @@ func deployCompose( } if dontWait { - log.Warn("skipping converge logic checks") + log.Warn(i18n.G("skipping converge logic checks")) return nil } @@ -339,11 +345,11 @@ func validateExternalNetworks(ctx context.Context, client dockerClient.NetworkAP network, err := client.NetworkInspect(ctx, networkName, networktypes.InspectOptions{}) switch { case dockerClient.IsErrNotFound(err): - return errors.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed, which you can do by running this on the server: docker network create -d overlay proxy", networkName) + return errors.New(i18n.G("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed, which you can do by running this on the server: docker network create -d overlay proxy", networkName)) case err != nil: return err case network.Scope != "swarm": - return errors.Errorf("network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\"", networkName, network.Scope) + return errors.New(i18n.G("network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\"", networkName, network.Scope)) } } return nil @@ -356,13 +362,13 @@ func createSecrets(ctx context.Context, cl *dockerClient.Client, secrets []swarm case err == nil: // secret already exists, then we update that if err := cl.SecretUpdate(ctx, secret.ID, secret.Meta.Version, secretSpec); err != nil { - return errors.Wrapf(err, "failed to update secret %s", secretSpec.Name) + return errors.Wrap(err, i18n.G("failed to update secret %s", secretSpec.Name)) } case dockerClient.IsErrNotFound(err): // secret does not exist, then we create a new one. - log.Infof("creating secret %s", secretSpec.Name) + log.Info(i18n.G("creating secret %s", secretSpec.Name)) if _, err := cl.SecretCreate(ctx, secretSpec); err != nil { - return errors.Wrapf(err, "failed to create secret %s", secretSpec.Name) + return errors.Wrap(err, i18n.G("failed to create secret %s", secretSpec.Name)) } default: return err @@ -378,13 +384,13 @@ func createConfigs(ctx context.Context, cl *dockerClient.Client, configs []swarm case err == nil: // config already exists, then we update that if err := cl.ConfigUpdate(ctx, config.ID, config.Meta.Version, configSpec); err != nil { - return errors.Wrapf(err, "failed to update config %s", configSpec.Name) + return errors.Wrap(err, i18n.G("failed to update config %s", configSpec.Name)) } case dockerClient.IsErrNotFound(err): // config does not exist, then we create a new one. 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) + return errors.Wrap(err, i18n.G("failed to create config %s", configSpec.Name)) } default: return err @@ -413,9 +419,9 @@ func createNetworks(ctx context.Context, cl *dockerClient.Client, namespace conv createOpts.Driver = defaultNetworkDriver } - log.Debugf("creating network %s", name) + log.Debug(i18n.G("creating network %s", name)) if _, err := cl.NetworkCreate(ctx, name, createOpts); err != nil { - return errors.Wrapf(err, "failed to create network %s", name) + return errors.Wrap(err, i18n.G("failed to create network %s", name)) } } return nil @@ -455,16 +461,16 @@ func deployServices( if sendAuth { dockerCLI, err := command.NewDockerCli() if err != nil { - log.Errorf("retrieving docker auth token: failed create docker cli: %s", err) + log.Error(i18n.G("retrieving docker auth token: failed create docker cli: %s", err)) } encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), image) if err != nil { - log.Errorf("failed to retrieve registry auth for image %s: %s", image, err) + log.Error(i18n.G("failed to retrieve registry auth for image %s: %s", image, err)) } } if service, exists := existingServiceMap[name]; exists { - log.Debugf("updating %s", name) + log.Debug(i18n.G("updating %s", name)) updateOpts := types.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth} @@ -499,7 +505,7 @@ func deployServices( response, err := cl.ServiceUpdate(ctx, service.ID, service.Version, serviceSpec, updateOpts) if err != nil { - return nil, errors.Wrapf(err, "failed to update %s", name) + return nil, errors.Wrap(err, i18n.G("failed to update %s", name)) } for _, warning := range response.Warnings { @@ -511,7 +517,7 @@ func deployServices( ID: service.ID, }) } else { - log.Debugf("creating %s", name) + log.Debug(i18n.G("creating %s", name)) createOpts := types.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth} @@ -522,7 +528,7 @@ func deployServices( serviceCreateResponse, err := cl.ServiceCreate(ctx, serviceSpec, createOpts) if err != nil { - return nil, errors.Wrapf(err, "failed to create %s", name) + return nil, errors.Wrap(err, i18n.G("failed to create %s", name)) } servicesMeta = append(servicesMeta, ui.ServiceMeta{ @@ -567,7 +573,7 @@ func WaitOnServices(ctx context.Context, cl *dockerClient.Client, opts WaitOpts) tui := tea.NewProgram(model) if !opts.Quiet { - log.Info("polling deployment status") + log.Info(i18n.G("polling deployment status")) } m, err := log.Without( @@ -576,7 +582,7 @@ func WaitOnServices(ctx context.Context, cl *dockerClient.Client, opts WaitOpts) }, ) if err != nil { - return fmt.Errorf("waitOnServices: error running TUI: %s", err) + return errors.New(i18n.G("waitOnServices: error running TUI: %s", err)) } deployModel := m.(ui.Model) @@ -584,16 +590,16 @@ func WaitOnServices(ctx context.Context, cl *dockerClient.Client, opts WaitOpts) var errs []error if deployModel.Failed { - errs = append(errs, fmt.Errorf("deploy failed 🛑")) + errs = append(errs, errors.New(i18n.G("deploy failed 🛑"))) } else if deployModel.TimedOut { - errs = append(errs, fmt.Errorf("deploy timed out 🟠")) + errs = append(errs, errors.New(i18n.G("deploy timed out 🟠"))) } else { - errs = append(errs, fmt.Errorf("deploy in progress 🟠")) + errs = append(errs, errors.New(i18n.G("deploy in progress 🟠"))) } for _, s := range *deployModel.Streams { if s.Err != nil { - errs = append(errs, fmt.Errorf("%s: %s", s.Name, s.Err)) + errs = append(errs, errors.New(i18n.G("%s: %s", s.Name, s.Err))) } } @@ -605,28 +611,28 @@ func WaitOnServices(ctx context.Context, cl *dockerClient.Client, opts WaitOpts) ) if err := os.MkdirAll(filepath.Join(config.LOGS_DIR, opts.ServerName), 0o764); err != nil { - return fmt.Errorf("waitOnServices: error creating log dir: %s", err) + return errors.New(i18n.G("waitOnServices: error creating log dir: %s", err)) } file, err := os.Create(logsPath) if err != nil { - return fmt.Errorf("waitOnServices: error opening file: %s", err) + return errors.New(i18n.G("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) + return errors.New(i18n.G("waitOnServices: writeFile: %s", err)) } - errs = append(errs, fmt.Errorf("logs: %s", logsPath)) + errs = append(errs, errors.New(i18n.G("logs: %s", logsPath))) } return stdlibErr.Join(errs...) } if !opts.Quiet { - log.Info("deploy succeeded 🟢") + log.Info(i18n.G("deploy succeeded 🟢")) } return nil @@ -646,8 +652,7 @@ func GetStacks(cl *dockerClient.Client) ([]*formatter.Stack, error) { labels := service.Spec.Labels name, ok := labels[convert.LabelNamespace] if !ok { - return nil, errors.Errorf("cannot get label %s for %s", - convert.LabelNamespace, service.ID) + return nil, errors.New(i18n.G("cannot get label %s for %s", convert.LabelNamespace, service.ID)) } ztack, ok := m[name] if !ok {