From 4a66df2d41f195ec08930527ad2e3234b73ca293 Mon Sep 17 00:00:00 2001 From: raideno56 Date: Sun, 8 Jun 2025 14:06:14 +0200 Subject: [PATCH 1/5] feat: add restart command --- cmd/restart.go | 35 ++++++++++++++++++++++++ internal/restart/restart.go | 41 ++++++++++++++++++++++++++++ internal/utils/docker.go | 54 +++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 cmd/restart.go create mode 100644 internal/restart/restart.go diff --git a/cmd/restart.go b/cmd/restart.go new file mode 100644 index 000000000..f0d1e3977 --- /dev/null +++ b/cmd/restart.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "os" + "os/signal" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/supabase/cli/internal/restart" +) + +var ( + restartProjectId string + restartAll bool + + restartCmd = &cobra.Command{ + GroupID: groupLocalDev, + Use: "restart", + Short: "Restart all local Supabase containers", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt) + return restart.Run(ctx, restartProjectId, restartAll, afero.NewOsFs()) + }, + } +) + +func init() { + flags := restartCmd.Flags() + flags.Bool("backup", true, "Backs up the current database before restarting.") + flags.StringVar(&restartProjectId, "project-id", "", "Local project ID to restart.") + cobra.CheckErr(flags.MarkHidden("backup")) + flags.BoolVar(&restartAll, "all", false, "Restart all local Supabase instances from all projects across the machine.") + restartCmd.MarkFlagsMutuallyExclusive("project-id", "all") + rootCmd.AddCommand(restartCmd) +} \ No newline at end of file diff --git a/internal/restart/restart.go b/internal/restart/restart.go new file mode 100644 index 000000000..6c0bd7e89 --- /dev/null +++ b/internal/restart/restart.go @@ -0,0 +1,41 @@ +package restart + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" +) + +func Run(ctx context.Context, projectId string, all bool, fsys afero.Fs) error { + var searchProjectIdFilter string + if !all { + // Sanity checks. + if len(projectId) > 0 { + utils.Config.ProjectId = projectId + } else if err := flags.LoadConfig(fsys); err != nil { + return err + } + searchProjectIdFilter = utils.Config.ProjectId + } + + // Restart all services + if err := utils.RunProgram(ctx, func(p utils.Program, ctx context.Context) error { + w := utils.StatusWriter{Program: p} + return restart(ctx, w, searchProjectIdFilter) + }); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Restarted %s local development setup.\n\n", utils.Aqua("supabase")) + + return nil +} + +func restart(ctx context.Context, w io.Writer, projectId string) error { + return utils.DockerRestartAll(ctx, w, projectId) +} diff --git a/internal/utils/docker.go b/internal/utils/docker.go index 1ee1da7ef..e9a93eafe 100644 --- a/internal/utils/docker.go +++ b/internal/utils/docker.go @@ -147,6 +147,60 @@ func DockerRemoveAll(ctx context.Context, w io.Writer, projectId string) error { return nil } +func DockerRestartAll(ctx context.Context, w io.Writer, projectId string) error { + fmt.Fprintln(w, "Restarting containers...") + args := CliProjectFilter(projectId) + containers, err := Docker.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: args, + }) + if err != nil { + return errors.Errorf("failed to list containers: %w", err) + } + // Restart containers + var ids []string + for _, c := range containers { + if c.State == "running" { + ids = append(ids, c.ID) + } + } + result := WaitAll(ids, func(id string) error { + if err := Docker.ContainerRestart(ctx, id, container.StopOptions{}); err != nil { + return errors.Errorf("failed to stop container: %w", err) + } + return nil + }) + if err := errors.Join(result...); err != nil { + return err + } + // if report, err := Docker.ContainersPrune(ctx, args); err != nil { + // return errors.Errorf("failed to prune containers: %w", err) + // } else if viper.GetBool("DEBUG") { + // fmt.Fprintln(os.Stderr, "Pruned containers:", report.ContainersDeleted) + // } + // // Remove named volumes + // if NoBackupVolume { + // vargs := args.Clone() + // if versions.GreaterThanOrEqualTo(Docker.ClientVersion(), "1.42") { + // // Since docker engine 25.0.3, all flag is required to include named volumes. + // // https://github.com/docker/cli/blob/master/cli/command/volume/prune.go#L76 + // vargs.Add("all", "true") + // } + // if report, err := Docker.VolumesPrune(ctx, vargs); err != nil { + // return errors.Errorf("failed to prune volumes: %w", err) + // } else if viper.GetBool("DEBUG") { + // fmt.Fprintln(os.Stderr, "Pruned volumes:", report.VolumesDeleted) + // } + // } + // // Remove networks. + // if report, err := Docker.NetworksPrune(ctx, args); err != nil { + // return errors.Errorf("failed to prune networks: %w", err) + // } else if viper.GetBool("DEBUG") { + // fmt.Fprintln(os.Stderr, "Pruned network:", report.NetworksDeleted) + // } + return nil +} + func CliProjectFilter(projectId string) filters.Args { if len(projectId) == 0 { return filters.NewArgs( From 2fab50c07c3e22d6315c10f9886fc1ac2fcc8afc Mon Sep 17 00:00:00 2001 From: raideno56 Date: Sun, 8 Jun 2025 14:06:49 +0200 Subject: [PATCH 2/5] test: add tests for restart command --- internal/restart/restart_test.go | 141 +++++++++++++++++++++++++++++ internal/testing/apitest/docker.go | 8 ++ 2 files changed, 149 insertions(+) create mode 100644 internal/restart/restart_test.go diff --git a/internal/restart/restart_test.go b/internal/restart/restart_test.go new file mode 100644 index 000000000..0b5441bf4 --- /dev/null +++ b/internal/restart/restart_test.go @@ -0,0 +1,141 @@ +package restart + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/h2non/gock" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" +) + +func TestRestartCommand(t *testing.T) { + t.Run("restart containers", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + // Setup mock docker + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). + Reply(http.StatusOK). + JSON([]container.Summary{}) + + // Run test + err := Run(context.Background(), "", false, fsys) + // Check error + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("restart all instances when --all flag is used", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + // Setup mock docker + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + + projects := []string{"project1", "project2"} + + // Mock initial ContainerList for all containers + gock.New(utils.Docker.DaemonHost()). + Get("/v"+utils.Docker.ClientVersion()+"/containers/json"). + MatchParam("all", "true"). + Reply(http.StatusOK). + JSON([]container.Summary{ + {ID: "container1", Labels: map[string]string{utils.CliProjectLabel: "project1"}}, + {ID: "container2", Labels: map[string]string{utils.CliProjectLabel: "project2"}}, + }) + + // Mock restartOneProject for each project + for _, projectId := range projects { + // Mock ContainerList for each project + gock.New(utils.Docker.DaemonHost()). + Get("/v"+utils.Docker.ClientVersion()+"/containers/json"). + MatchParam("all", "1"). + MatchParam("filters", fmt.Sprintf(`{"label":{"com.supabase.cli.project=%s":true}}`, projectId)). + Reply(http.StatusOK). + JSON([]container.Summary{{ID: "container-" + projectId, State: "running"}}) + + // Mock container restart + gock.New(utils.Docker.DaemonHost()). + Post("/v" + utils.Docker.ClientVersion() + "/containers/container-" + projectId + "/restart"). + Reply(http.StatusOK) + } + + // Mock final ContainerList to verify all containers are restarted + gock.New(utils.Docker.DaemonHost()). + Get("/v"+utils.Docker.ClientVersion()+"/containers/json"). + MatchParam("all", "true"). + Reply(http.StatusOK). + JSON([]container.Summary{}) + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). + Reply(http.StatusOK). + JSON([]container.Summary{}) + + // Run test + err := Run(context.Background(), "", true, fsys) + + // Check error + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("throws error on malformed config", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644)) + // Run test + err := Run(context.Background(), "", false, fsys) + // Check error + assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there") + }) + + t.Run("throws error on restart failure", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + require.NoError(t, utils.WriteConfig(fsys, false)) + // Setup mock docker + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). + Reply(http.StatusServiceUnavailable) + // Run test + err := Run(context.Background(), "test", false, afero.NewReadOnlyFs(fsys)) + // Check error + assert.ErrorContains(t, err, "request returned 503 Service Unavailable for API route and version") + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} + +func TestRestartServices(t *testing.T) { + t.Run("restart all services", func(t *testing.T) { + containers := []container.Summary{{ID: "c1", State: "running"}, {ID: "c2"}} + // Setup mock docker + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). + Reply(http.StatusOK). + JSON(containers) + gock.New(utils.Docker.DaemonHost()). + Post("/v" + utils.Docker.ClientVersion() + "/containers/" + containers[0].ID + "/restart"). + Reply(http.StatusOK) + // Run test + err := restart(context.Background(), io.Discard, utils.Config.ProjectId) + // Check error + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} diff --git a/internal/testing/apitest/docker.go b/internal/testing/apitest/docker.go index 17a14355d..5f9acb99d 100644 --- a/internal/testing/apitest/docker.go +++ b/internal/testing/apitest/docker.go @@ -82,6 +82,14 @@ func MockDockerStop(docker *client.Client) { JSON(network.PruneReport{}) } +// Ref: internal/utils/docker.go::DockerRestartAll +func MockDockerRestart(docker *client.Client) { + gock.New(docker.DaemonHost()). + Get("/v" + docker.ClientVersion() + "/containers/json"). + Reply(http.StatusOK). + JSON([]container.Summary{}) +} + // Ref: internal/utils/docker.go::DockerRunOnce func setupDockerLogs(docker *client.Client, containerID, stdout string, exitCode int) error { var body bytes.Buffer From 2355a070ecbcfd631072dcdbfca8b26c38b566c4 Mon Sep 17 00:00:00 2001 From: raideno56 Date: Sun, 8 Jun 2025 14:07:11 +0200 Subject: [PATCH 3/5] docs: add documentation for restart command --- docs/supabase/restart.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/supabase/restart.md diff --git a/docs/supabase/restart.md b/docs/supabase/restart.md new file mode 100644 index 000000000..e0a2fe136 --- /dev/null +++ b/docs/supabase/restart.md @@ -0,0 +1,9 @@ +## supabase-restart + +Restarts the Supabase local development stack. + +Requires `supabase/config.toml` to be created in your current working directory by running `supabase init`. + +This command uses Docker's native restart functionality to efficiently restart running containers without fully stopping and starting them. This approach is faster and maintains container state better than separate stop/start operations. + +Use the `--all` flag to stop all local Supabase projects instances on the machine. From 99c0d7f1f5b86fd91a3b4fdafa19a883d1e0fde0 Mon Sep 17 00:00:00 2001 From: raideno56 Date: Sun, 8 Jun 2025 14:11:38 +0200 Subject: [PATCH 4/5] chore: remove unnecessary commented code --- internal/utils/docker.go | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/internal/utils/docker.go b/internal/utils/docker.go index e9a93eafe..22251d694 100644 --- a/internal/utils/docker.go +++ b/internal/utils/docker.go @@ -173,31 +173,7 @@ func DockerRestartAll(ctx context.Context, w io.Writer, projectId string) error if err := errors.Join(result...); err != nil { return err } - // if report, err := Docker.ContainersPrune(ctx, args); err != nil { - // return errors.Errorf("failed to prune containers: %w", err) - // } else if viper.GetBool("DEBUG") { - // fmt.Fprintln(os.Stderr, "Pruned containers:", report.ContainersDeleted) - // } - // // Remove named volumes - // if NoBackupVolume { - // vargs := args.Clone() - // if versions.GreaterThanOrEqualTo(Docker.ClientVersion(), "1.42") { - // // Since docker engine 25.0.3, all flag is required to include named volumes. - // // https://github.com/docker/cli/blob/master/cli/command/volume/prune.go#L76 - // vargs.Add("all", "true") - // } - // if report, err := Docker.VolumesPrune(ctx, vargs); err != nil { - // return errors.Errorf("failed to prune volumes: %w", err) - // } else if viper.GetBool("DEBUG") { - // fmt.Fprintln(os.Stderr, "Pruned volumes:", report.VolumesDeleted) - // } - // } - // // Remove networks. - // if report, err := Docker.NetworksPrune(ctx, args); err != nil { - // return errors.Errorf("failed to prune networks: %w", err) - // } else if viper.GetBool("DEBUG") { - // fmt.Fprintln(os.Stderr, "Pruned network:", report.NetworksDeleted) - // } + return nil } From b403d862ad01f9cb273e6cbd1538844698152082 Mon Sep 17 00:00:00 2001 From: raideno56 Date: Sun, 8 Jun 2025 14:21:33 +0200 Subject: [PATCH 5/5] style: fix linting issue --- cmd/restart.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/restart.go b/cmd/restart.go index f0d1e3977..2fe6dddaf 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -32,4 +32,4 @@ func init() { flags.BoolVar(&restartAll, "all", false, "Restart all local Supabase instances from all projects across the machine.") restartCmd.MarkFlagsMutuallyExclusive("project-id", "all") rootCmd.AddCommand(restartCmd) -} \ No newline at end of file +}