diff --git a/cmd/uc/deploy.go b/cmd/uc/deploy.go index 4974c59c..912371fa 100644 --- a/cmd/uc/deploy.go +++ b/cmd/uc/deploy.go @@ -38,8 +38,9 @@ type deployOptions struct { func NewDeployCommand() *cobra.Command { opts := deployOptions{} cmd := &cobra.Command{ - Use: "deploy [FLAGS] [SERVICE...]", - Short: "Deploy services from a Compose file.", + Use: "deploy [FLAGS] [SERVICE...]", + Aliases: []string{"up"}, + Short: "Deploy services from a Compose file.", RunE: func(cmd *cobra.Command, args []string) error { cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM") diff --git a/cmd/uc/destroy.go b/cmd/uc/destroy.go new file mode 100644 index 00000000..eba843ef --- /dev/null +++ b/cmd/uc/destroy.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "charm.land/lipgloss/v2" + composecli "github.com/compose-spec/compose-go/v2/cli" + "github.com/docker/compose/v2/pkg/progress" + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/completion" + "github.com/psviderski/uncloud/internal/cli/tui" + "github.com/psviderski/uncloud/pkg/api" + "github.com/psviderski/uncloud/pkg/client/compose" + "github.com/spf13/cobra" +) + +type destroyOptions struct { + cli.BuildServicesOptions + + files []string + profiles []string + services []string + yes bool +} + +// NewDestroyCommand creates a new command to tear down services from a Compose file. +func NewDestroyCommand() *cobra.Command { + opts := destroyOptions{} + cmd := &cobra.Command{ + Use: "destroy [FLAGS] [SERVICE...]", + Aliases: []string{"down", "undeploy"}, + Short: "Destroy services from a Compose file.", + Long: `Destroy services from a Compose file. + +Destroy removes all containers of the specified service(s) across all machines in the cluster. + +See "uc service remove".`, + RunE: func(cmd *cobra.Command, args []string) error { + cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM") + + uncli := cmd.Context().Value("cli").(*cli.CLI) + opts.services = args + + return runDestroy(cmd.Context(), uncli, opts) + }, + GroupID: "service", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return completion.ComposeServices(cmd.Context(), args, toComplete, opts.files, opts.profiles) + }, + } + + cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil, + "One or more Compose files to destroy services from. (default compose.yaml)") + cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil, + "One or more Compose profiles to enable.") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, + "Auto-confirm deployment plan. Should be explicitly set when running non-interactively,\n"+ + "e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]") + + return cmd +} + +// runDestroy parses the Compose file(s) and destroys the services. +func runDestroy(ctx context.Context, uncli *cli.CLI, opts destroyOptions) error { + project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...)) + if err != nil { + return fmt.Errorf("load compose file(s): %w", err) + } + + uncli.SetClusterContextIfUnset(compose.ClusterContext(project)) + + if len(opts.services) > 0 { + project, err = project.WithSelectedServices(opts.services) + if err != nil { + return fmt.Errorf("select services: %w", err) + } + } + + composeServices := append(project.ServiceNames(), project.DisabledServiceNames()...) + if len(composeServices) == 0 { + return errors.New("no services found in Compose file(s)") + } + + client, err := uncli.ConnectCluster(ctx) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + services := []api.Service{} + for _, service := range composeServices { + svc, err := client.InspectService(ctx, service) + if err != nil { + return fmt.Errorf("inspect service: %w", err) + } + services = append(services, svc) + } + + t := tui.NewTable() + headers := []string{"NAME", "MODE", "REPLICAS"} + t.Headers(headers...) + + for _, s := range services { + row := []string{s.Name, s.Mode, fmt.Sprintf("%d", len(s.Containers))} + t.Row(row...) + } + fmt.Println(t) + + if !opts.yes { + if !tui.IsStdinTerminal() { + return errors.New("cannot ask to confirm destroy in non-interactive mode, " + + "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") + } + + directConn := uncli.DirectConnection() + contextName := uncli.ContextOverrideOrCurrent() + destroyTarget := "" + if directConn != "" { + destroyTarget = directConn + fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn)) + fmt.Println() + } else if contextName != "" && len(uncli.Config.Contexts) > 1 { + destroyTarget = contextName + fmt.Println(tui.Faint.Render("context: ") + tui.NameStyle.Render(contextName)) + fmt.Println() + } + + title := "Proceed with destroy?" + // Include the direct connection or context name in the confirmation prompt to avoid accidentally + // destroying in the wrong cluster. + if destroyTarget != "" { + isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title + title = "Proceed with destroy to " + tui.NameStyle.Render(destroyTarget) + confirmStyle.Render("?") + } + + fmt.Println() + confirmed, err := tui.Confirm(title) + if err != nil { + return fmt.Errorf("confirm destroy: %w", err) + } + if !confirmed { + return cli.Cancelled("Destroy cancelled. No changes were made.") + } + } + + if opts.yes { + fmt.Println() // slightly nicer in the output + } + for _, s := range composeServices { + err = progress.RunWithTitle(ctx, func(ctx context.Context) error { + if err = client.RemoveService(ctx, s); err != nil { + return fmt.Errorf("destroying service '%s': %w", s, err) + } + return nil + }, uncli.ProgressOut(), "Destroy service "+s) + } + + return err +} diff --git a/cmd/uc/main.go b/cmd/uc/main.go index e15f6d81..133ee2c1 100644 --- a/cmd/uc/main.go +++ b/cmd/uc/main.go @@ -136,6 +136,7 @@ func main() { cmd.AddCommand( NewBuildCommand(), NewDeployCommand(), + NewDestroyCommand(), NewDocsCommand(), NewImagesCommand(), NewPsCommand(), diff --git a/website/docs/9-cli-reference/uc.md b/website/docs/9-cli-reference/uc.md index feb32eee..d7cdf28d 100644 --- a/website/docs/9-cli-reference/uc.md +++ b/website/docs/9-cli-reference/uc.md @@ -18,6 +18,7 @@ A CLI tool for managing Uncloud resources such as machines, services, and volume * [uc caddy](uc_caddy.md) - Manage Caddy reverse proxy service. * [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts. * [uc deploy](uc_deploy.md) - Deploy services from a Compose file. +* [uc destroy](uc_destroy.md) - Destroy services from a Compose file. * [uc dns](uc_dns.md) - Manage cluster domain in Uncloud DNS. * [uc exec](uc_exec.md) - Execute a command in a running service container. * [uc image](uc_image.md) - Manage images on machines in the cluster. diff --git a/website/docs/9-cli-reference/uc_destroy.md b/website/docs/9-cli-reference/uc_destroy.md new file mode 100644 index 00000000..e61cdafb --- /dev/null +++ b/website/docs/9-cli-reference/uc_destroy.md @@ -0,0 +1,39 @@ +# uc destroy + +Destroy services from a Compose file. + +## Synopsis + +Destroy services from a Compose file. + +Destroy removes all containers of the specified service(s) across all machines in the cluster. + +See "uc service remove". + +``` +uc destroy [FLAGS] [SERVICE...] [flags] +``` + +## Options + +``` + -f, --file strings One or more Compose files to destroy services from. (default compose.yaml) + -h, --help help for destroy + -p, --profile strings One or more Compose profiles to enable. + -y, --yes Auto-confirm deployment plan. Should be explicitly set when running non-interactively, + e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM] +``` + +## Options inherited from parent commands + +``` + --connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT] + Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock + -c, --context string Name of the cluster context to use (default is the current context). [$UNCLOUD_CONTEXT] + --uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml") +``` + +## See also + +* [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. +