From 4df233d3d7881bf6f1a6e16b0f76eaf3a00a3186 Mon Sep 17 00:00:00 2001 From: Miek Gieben Date: Sun, 7 Jun 2026 11:32:30 +0200 Subject: [PATCH 1/5] feat: add destroy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds destroy (aliases: down, undeploy) which calls out to client.RemoveService to remove services from the cluster. It is the opposite of "deploy" (which now has the alias "up") This was mostly cut&paste of existing functionality. Cancelling the operation: ``` % ~miekg/uncloud/cmd/uncloud/uc destroy NAME MODE REPLICAS unkey global 3 Proceed with destroy? [y/N] n Destroy cancelled. No changes were made. ``` Giving a service: ``` ~miekg/uncloud/cmd/uncloud/uc destroy ds Error: select services: no such service: ds ``` Destroying takes place: ``` ``` NAME MODE REPLICAS unkey global 3 Proceed with destroy? [y/N] y [+] Destroy service unkey 3/3 ✔ Container unkey/cafab403586d on machine-11zv Removed 3.9s ✔ Container unkey/f0773dc8c97d on machine-z00h Removed 4.6s ✔ Container unkey/b1b428fabcd8 on machine-36i2 Removed 4.1s ``` Signed-off-by: Miek Gieben --- cmd/uc/deploy.go | 5 +- cmd/uc/destroy.go | 163 +++++++++++++++++++++ cmd/uc/main.go | 1 + website/docs/9-cli-reference/uc.md | 1 + website/docs/9-cli-reference/uc_destroy.md | 39 +++++ 5 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 cmd/uc/destroy.go create mode 100644 website/docs/9-cli-reference/uc_destroy.md 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..ee4c58c3 --- /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 + signal string + timeout int +} + +// 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 deploy 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 detroys 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 undeployment in non-interactive mode, " + + "use --yes flag or set UNCLOUD_AUTO_CONFIRM=true to auto-confirm") + } + + directConn := uncli.DirectConnection() + contextName := uncli.ContextOverrideOrCurrent() + deployTarget := "" + if directConn != "" { + deployTarget = directConn + fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn)) + fmt.Println() + } else if contextName != "" && len(uncli.Config.Contexts) > 1 { + // Only show context if there's more than one to avoid unnecessary clutter. + deployTarget = 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 + // deploying to the wrong cluster. + if deployTarget != "" { + isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title + title = "Proceed with detroy to " + tui.NameStyle.Render(deployTarget) + 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.") + } + } + + 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..9224b913 --- /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 deploy 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. + From 9d09df9ff30649f2f26b3a8c47964b7c53422a76 Mon Sep 17 00:00:00 2001 From: Miek Gieben Date: Tue, 9 Jun 2026 10:30:56 +0200 Subject: [PATCH 2/5] output tweak Signed-off-by: Miek Gieben --- cmd/uc/destroy.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/uc/destroy.go b/cmd/uc/destroy.go index ee4c58c3..a0334953 100644 --- a/cmd/uc/destroy.go +++ b/cmd/uc/destroy.go @@ -150,6 +150,9 @@ func runDestroy(ctx context.Context, uncli *cli.CLI, opts destroyOptions) error } } + 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 { From a287f4d28b289684f5150ee6fde4ea0a318f6cfc Mon Sep 17 00:00:00 2001 From: Miek Gieben Date: Tue, 9 Jun 2026 10:39:57 +0200 Subject: [PATCH 3/5] Remove unused fields from struct Signed-off-by: Miek Gieben --- cmd/uc/destroy.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/uc/destroy.go b/cmd/uc/destroy.go index a0334953..7ea1db9f 100644 --- a/cmd/uc/destroy.go +++ b/cmd/uc/destroy.go @@ -24,8 +24,6 @@ type destroyOptions struct { profiles []string services []string yes bool - signal string - timeout int } // NewDestroyCommand creates a new command to tear down services from a Compose file. From 8ede04c6525d23ce1f592c7edec2fd46ad715c52 Mon Sep 17 00:00:00 2001 From: Miek Gieben Date: Wed, 10 Jun 2026 07:05:05 +0200 Subject: [PATCH 4/5] s/deploy/destroy/ Signed-off-by: Miek Gieben --- cmd/uc/destroy.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/cmd/uc/destroy.go b/cmd/uc/destroy.go index 7ea1db9f..eba843ef 100644 --- a/cmd/uc/destroy.go +++ b/cmd/uc/destroy.go @@ -53,7 +53,7 @@ See "uc service remove".`, } cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil, - "One or more Compose files to deploy services from. (default compose.yaml)") + "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, @@ -63,7 +63,7 @@ See "uc service remove".`, return cmd } -// runDestroy parses the Compose file(s) and detroys the services. +// 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 { @@ -111,31 +111,30 @@ func runDestroy(ctx context.Context, uncli *cli.CLI, opts destroyOptions) error if !opts.yes { if !tui.IsStdinTerminal() { - return errors.New("cannot ask to confirm undeployment in non-interactive mode, " + + 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() - deployTarget := "" + destroyTarget := "" if directConn != "" { - deployTarget = directConn + destroyTarget = directConn fmt.Println(tui.Faint.Render("connection: ") + tui.NameStyle.Render(directConn)) fmt.Println() } else if contextName != "" && len(uncli.Config.Contexts) > 1 { - // Only show context if there's more than one to avoid unnecessary clutter. - deployTarget = contextName + 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 - // deploying to the wrong cluster. - if deployTarget != "" { + // destroying in the wrong cluster. + if destroyTarget != "" { isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) confirmStyle := tui.ThemeConfirm().Theme(isDark).Focused.Title - title = "Proceed with detroy to " + tui.NameStyle.Render(deployTarget) + confirmStyle.Render("?") + title = "Proceed with destroy to " + tui.NameStyle.Render(destroyTarget) + confirmStyle.Render("?") } fmt.Println() From 5b11cf6d8038e456c830e3052d8c3fabec0dbbbe Mon Sep 17 00:00:00 2001 From: Miek Gieben Date: Wed, 10 Jun 2026 07:10:31 +0200 Subject: [PATCH 5/5] cli docs Signed-off-by: Miek Gieben --- website/docs/9-cli-reference/uc_destroy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/9-cli-reference/uc_destroy.md b/website/docs/9-cli-reference/uc_destroy.md index 9224b913..e61cdafb 100644 --- a/website/docs/9-cli-reference/uc_destroy.md +++ b/website/docs/9-cli-reference/uc_destroy.md @@ -17,7 +17,7 @@ uc destroy [FLAGS] [SERVICE...] [flags] ## Options ``` - -f, --file strings One or more Compose files to deploy services from. (default compose.yaml) + -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,