diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b9a4539..6438b2f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 1.78.4 +### Improvements + +- Compute Instance delete: Remove multiple entities by their IDs/Names #619 + ### Bug Fixes - output template: use text/template #617 diff --git a/cmd/instance_delete.go b/cmd/instance_delete.go index 1667300d..01f734d2 100644 --- a/cmd/instance_delete.go +++ b/cmd/instance_delete.go @@ -1,16 +1,15 @@ package cmd import ( - "errors" "fmt" "os" "path" + "strings" "github.com/spf13/cobra" - "github.com/exoscale/cli/pkg/account" "github.com/exoscale/cli/pkg/globalstate" - exoapi "github.com/exoscale/egoscale/v2/api" + v3 "github.com/exoscale/egoscale/v3" ) type instanceDeleteCmd struct { @@ -18,7 +17,7 @@ type instanceDeleteCmd struct { _ bool `cli-cmd:"delete"` - Instance string `cli-arg:"#" cli-usage:"NAME|ID"` + Instances []string `cli-arg:"#" cli-usage:"NAME|ID"` Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"` Zone string `cli-short:"z" cli-usage:"instance zone"` @@ -36,33 +35,67 @@ func (c *instanceDeleteCmd) cmdPreRun(cmd *cobra.Command, args []string) error { } func (c *instanceDeleteCmd) cmdRun(_ *cobra.Command, _ []string) error { - ctx := exoapi.WithEndpoint(gContext, exoapi.NewReqEndpoint(account.CurrentAccount.Environment, c.Zone)) + ctx := gContext + client, err := switchClientZoneV3( + ctx, + globalstate.EgoscaleV3Client, + v3.ZoneName(c.Zone), + ) + if err != nil { + return err + } - instance, err := globalstate.EgoscaleClient.FindInstance(ctx, c.Zone, c.Instance) + instances, err := client.ListInstances(ctx) if err != nil { - if errors.Is(err, exoapi.ErrNotFound) { - return fmt.Errorf("resource not found in zone %q", c.Zone) - } return err } - if !c.Force { - if !askQuestion(fmt.Sprintf("Are you sure you want to delete instance %q?", c.Instance)) { - return nil + instanceToDelete := []v3.UUID{} + for _, i := range c.Instances { + instance, err := instances.FindListInstancesResponseInstances(i) + if err != nil { + if !c.Force { + return err + } + fmt.Fprintf(os.Stderr, "warning: %s not found.\n", i) + + continue } + + if !c.Force { + if !askQuestion(fmt.Sprintf("Are you sure you want to delete instance %q?", i)) { + return nil + } + } + + instanceToDelete = append(instanceToDelete, instance.ID) + } + + var fns []func() error + for _, i := range instanceToDelete { + fns = append(fns, func() error { + op, err := client.DeleteInstance(ctx, i) + if err != nil { + return err + } + _, err = client.Wait(ctx, op, v3.OperationStateSuccess) + return err + }) } - decorateAsyncOperation(fmt.Sprintf("Deleting instance %q...", c.Instance), func() { - err = globalstate.EgoscaleClient.DeleteInstance(ctx, c.Zone, instance) - }) + err = decorateAsyncOperations(fmt.Sprintf("Deleting instance %q...", strings.Join(c.Instances, ", ")), fns...) if err != nil { return err } - instanceDir := path.Join(globalstate.ConfigFolder, "instances", *instance.ID) - if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { - if err := os.RemoveAll(instanceDir); err != nil { - return fmt.Errorf("error deleting instance directory: %w", err) + // Cleaning up resources created in create instance + // https://github.com/exoscale/cli/blob/master/cmd/instance_create.go#L220 + for _, i := range instanceToDelete { + instanceDir := path.Join(globalstate.ConfigFolder, "instances", i.String()) + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + if err := os.RemoveAll(instanceDir); err != nil { + return fmt.Errorf("error deleting instance directory: %w", err) + } } } diff --git a/cmd/output.go b/cmd/output.go index 7d2f4674..3714658c 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "github.com/hashicorp/go-multierror" "github.com/spf13/cobra" "github.com/vbauerster/mpb/v4" "github.com/vbauerster/mpb/v4/decor" @@ -46,6 +47,7 @@ func printOutput(o output.Outputter, err error) error { // decorateAsyncOperation is a cosmetic helper intended for wrapping long // asynchronous operations, outputting progress feedback to the user's // terminal. +// TODO remove this one once all has been migrated to decorateAsyncOperations. func decorateAsyncOperation(message string, fn func()) { p := mpb.New( mpb.WithOutput(os.Stderr), @@ -75,6 +77,54 @@ func decorateAsyncOperation(message string, fn func()) { p.Wait() } +func decorateAsyncOperations(message string, fns ...func() error) error { + if len(fns) == 0 { + return nil + } + + p := mpb.New( + mpb.WithOutput(os.Stderr), + mpb.WithWidth(1), + mpb.ContainerOptOn(mpb.WithOutput(nil), func() bool { return globalstate.Quiet }), + ) + + spinner := p.AddSpinner( + int64(len(fns)), + mpb.SpinnerOnLeft, + mpb.AppendDecorators( + decor.Name(message, decor.WC{W: len(message) + 1, C: decor.DidentRight}), + decor.Elapsed(decor.ET_STYLE_GO), + ), + mpb.BarOnComplete("✔"), + ) + + errs := &multierror.Error{} + done := make(chan struct{}) + defer close(done) + + for i := 0; i < len(fns); i += 10 { + batchSize := min(10, len(fns)-i) + for j := 0; j < batchSize; j++ { + fnIndex := i + j + go func(doneCh chan struct{}, fn func() error) { + if err := fn(); err != nil { + errs = multierror.Append(errs, err) + } + doneCh <- struct{}{} + }(done, fns[fnIndex]) + } + + for j := 0; j < batchSize; j++ { + <-done + spinner.Increment(1) + } + } + + p.Wait() + + return errs.ErrorOrNil() +} + func init() { RootCmd.AddCommand(&cobra.Command{ Use: "output",