Repository: supabase/cli
Pull request #788 (2023-01-12 07:35:49)

feat: use named volumes for database backup
## What kind of change does this PR introduce?

feature

## What is the current behavior?

`stop --backup` creates a dump.sql which is prone to breaking changes in postgres image, ie. removing pgsodium.
It also requires custom flags which is difficult to maintain.

## What is the new behavior?

`db start` will create a named docker volume mounted at `/var/lib/postgresql/data`.
This volume can be persisted after `stop --backup` for reuse.

## Additional context

Add any other context or screenshots.


coveralls: ## Pull Request Test Coverage Report for [Build 3900136709](https://coveralls.io/builds/55878526)

* **47** of **61**   **(77.05%)**  changed or added relevant lines in **5** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.2%**) to **63.023%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [internal/db/start/start.go](https://coveralls.io/builds/55878526/source?filename=internal%2Fdb%2Fstart%2Fstart.go#L38) | 9 | 14 | 64.29%
| [internal/utils/docker.go](https://coveralls.io/builds/55878526/source?filename=internal%2Futils%2Fdocker.go#L113) | 13 | 22 | 59.09%
<!-- | **Total:** | **47** | **61** | **77.05%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/55878526/badge)](https://coveralls.io/builds/55878526) |
| :-- | --: |
| Change from base [Build 3893621394](https://coveralls.io/builds/55850420): |  0.2% |
| Covered Lines: | 3569 |
| Relevant Lines: | 5663 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/go.mod b/go.mod
index 9de0273e..dce14362 100644
--- a/go.mod
+++ b/go.mod
@@ -41,8 +41,10 @@ require (
 	github.com/go-playground/validator/v10 v10.11.1 // indirect
 	github.com/goccy/go-json v0.9.11 // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
+	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
+	github.com/imdario/mergo v0.3.13 // indirect
 	github.com/invopop/yaml v0.1.0 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
@@ -62,6 +64,9 @@ require (
 	github.com/ugorji/go/codec v1.2.7 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasttemplate v1.2.2 // indirect
+	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
+	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 	golang.org/x/mod v0.7.0 // indirect
 	golang.org/x/tools v0.3.0 // indirect
 	gotest.tools/v3 v3.0.3 // indirect
diff --git a/go.sum b/go.sum
index 3b8bd4ae..cb424ee2 100644
--- a/go.sum
+++ b/go.sum
@@ -211,6 +211,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -228,6 +230,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
+github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc=
@@ -464,6 +468,12 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
 github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
 github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 h1:MzD3XeOOSO3mAjOPpF07jFteSKZxsRHvlIcAR9RQzKM=
 github.com/withfig/autocomplete-tools/packages/cobra v1.2.0/go.mod h1:RoXh7+7qknOXL65uTzdzE1mPxqcPwS7FLCE9K5GfmKo=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
diff --git a/internal/db/reset/reset.go b/internal/db/reset/reset.go
index 85685205..79ae2907 100644
--- a/internal/db/reset/reset.go
+++ b/internal/db/reset/reset.go
@@ -152,12 +152,12 @@ func RestartDatabase(ctx context.Context) {
 	}
 }
 
-func RetryEverySecond(callback func() bool, timeout time.Duration) bool {
+func RetryEverySecond(ctx context.Context, callback func() bool, timeout time.Duration) bool {
 	now := time.Now()
 	expiry := now.Add(timeout)
 	ticker := time.NewTicker(time.Second)
 	defer ticker.Stop()
-	for t := now; t.Before(expiry); t = <-ticker.C {
+	for t := now; t.Before(expiry) && ctx.Err() == nil; t = <-ticker.C {
 		if callback() {
 			return true
 		}
@@ -169,5 +169,5 @@ func WaitForHealthyService(ctx context.Context, container string, timeout time.D
 	probe := func() bool {
 		return status.AssertContainerHealthy(ctx, container) == nil
 	}
-	return RetryEverySecond(probe, timeout)
+	return RetryEverySecond(ctx, probe, timeout)
 }
diff --git a/internal/db/start/start.go b/internal/db/start/start.go
index caa03cf3..0e484097 100644
--- a/internal/db/start/start.go
+++ b/internal/db/start/start.go
@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/client"
 	"github.com/docker/go-connections/nat"
 	"github.com/jackc/pgx/v4"
 	"github.com/spf13/afero"
@@ -34,7 +35,11 @@ func Run(ctx context.Context, fsys afero.Fs) error {
 		fmt.Fprintln(os.Stderr, "Postgres database is already running.")
 		return nil
 	}
-	return StartDatabase(ctx, fsys, os.Stderr)
+	err := StartDatabase(ctx, fsys, os.Stderr)
+	if err != nil {
+		utils.DockerRemoveAll(context.Background())
+	}
+	return err
 }
 
 func StartDatabase(ctx context.Context, fsys afero.Fs, w io.Writer, options ...func(*pgx.ConnConfig)) error {
@@ -71,17 +76,19 @@ func StartDatabase(ctx context.Context, fsys afero.Fs, w io.Writer, options ...f
 	hostConfig := container.HostConfig{
 		PortBindings:  nat.PortMap{"5432/tcp": []nat.PortBinding{{HostPort: hostPort}}},
 		RestartPolicy: container.RestartPolicy{Name: "always"},
-		Binds:         []string{"/dev/null:/docker-entrypoint-initdb.d/migrate.sh:ro"},
+		Binds: []string{
+			utils.DbId + ":/var/lib/postgresql/data",
+			"/dev/null:/docker-entrypoint-initdb.d/migrate.sh:ro",
+		},
 	}
 	fmt.Fprintln(w, "Starting database...")
-	if _, err := utils.DockerStart(ctx, config, hostConfig, utils.DbId); err != nil {
+	// Creating volume will not override existing volume, so we must inspect explicitly
+	_, err := utils.Docker.VolumeInspect(ctx, utils.DbId)
+	exists := !client.IsErrNotFound(err)
+	if _, err := utils.DockerStart(ctx, config, hostConfig, utils.DbId); err != nil || exists {
 		return err
 	}
-	err := initDatabase(ctx, fsys, w, options...)
-	if err != nil {
-		utils.DockerRemove(utils.DbId)
-	}
-	return err
+	return initDatabase(ctx, fsys, w, options...)
 }
 
 func initDatabase(ctx context.Context, fsys afero.Fs, w io.Writer, options ...func(*pgx.ConnConfig)) error {
diff --git a/internal/start/start.go b/internal/start/start.go
index fb9af7ce..f94d84b5 100644
--- a/internal/start/start.go
+++ b/internal/start/start.go
@@ -42,7 +42,7 @@ func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string) error
 	if err := utils.RunProgram(ctx, func(p utils.Program, ctx context.Context) error {
 		return run(p, ctx, fsys, excludedContainers)
 	}); err != nil {
-		utils.DockerRemoveAll(context.Background(), utils.NetId)
+		utils.DockerRemoveAll(context.Background())
 		return err
 	}
 
@@ -323,9 +323,6 @@ EOF
 			ctx,
 			container.Config{
 				Image: utils.StorageImage,
-				Volumes: map[string]struct{}{
-					"/var/lib/storage": {},
-				},
 				Env: []string{
 					"ANON_KEY=" + utils.AnonKey,
 					"SERVICE_KEY=" + utils.ServiceRoleKey,
@@ -351,6 +348,7 @@ EOF
 			},
 			container.HostConfig{
 				RestartPolicy: container.RestartPolicy{Name: "always"},
+				Binds:         []string{utils.StorageId + ":/var/lib/storage"},
 			},
 			utils.StorageId,
 		); err != nil {
@@ -478,7 +476,7 @@ func waitForServiceReady(ctx context.Context, started []string) error {
 		started = unhealthy
 		return len(started) == 0
 	}
-	if !reset.RetryEverySecond(probe, 20*time.Second) {
+	if !reset.RetryEverySecond(ctx, probe, 20*time.Second) {
 		return fmt.Errorf("service not healthy: %v", started)
 	}
 	return nil
diff --git a/internal/stop/stop.go b/internal/stop/stop.go
index 855ecc1e..80224836 100644
--- a/internal/stop/stop.go
+++ b/internal/stop/stop.go
@@ -3,11 +3,8 @@ package stop
 import (
 	"context"
 	_ "embed"
-	"errors"
 	"fmt"
 	"os"
-	"path/filepath"
-	"strings"
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/filters"
@@ -15,67 +12,22 @@ import (
 	"github.com/supabase/cli/internal/utils"
 )
 
-var (
-	//go:embed templates/dump.sh
-	dumpScript    string
-	ignoreSchemas = []string{
-		"extensions",
-		"pgbouncer",
-		"pgsodium",
-		"pgsodium_masks",
-	}
-)
-
 func Run(ctx context.Context, backup bool, fsys afero.Fs) error {
 	// Sanity checks.
 	if err := utils.LoadConfigFS(fsys); err != nil {
 		return err
 	}
 
-	if backup {
-		if err := backupDatabase(ctx, fsys); err != nil {
-			return err
-		}
-	}
-
 	// Stop all services
-	if err := stop(ctx); err != nil {
+	if err := stop(ctx, backup); err != nil {
 		return err
 	}
-	fmt.Println("Stopped " + utils.Aqua("supabase") + " local development setup.")
-
-	if !backup {
-		// Remove other branches
-		branchDir := filepath.Dir(utils.CurrBranchPath)
-		if err := fsys.RemoveAll(branchDir); err != nil {
-			fmt.Fprintln(os.Stderr, err)
-		}
-	}
 
+	fmt.Println("Stopped " + utils.Aqua("supabase") + " local development setup.")
 	return nil
 }
 
-func backupDatabase(ctx context.Context, fsys afero.Fs) error {
-	out, err := utils.DockerRunOnce(ctx, utils.Pg15Image, []string{
-		"EXCLUDED_SCHEMAS=" + strings.Join(ignoreSchemas, "|"),
-		"DB_URL=postgresql://postgres:postgres@" + utils.DbId + ":5432/postgres",
-	}, []string{"bash", "-c", dumpScript})
-	if err != nil {
-		return errors.New("Error running pg_dump on local database: " + err.Error())
-	}
-	branch, err := utils.GetCurrentBranchFS(fsys)
-	if err != nil {
-		branch = "main"
-	}
-	branchDir := filepath.Join(filepath.Dir(utils.CurrBranchPath), branch)
-	if err := utils.MkdirIfNotExistFS(fsys, branchDir); err != nil {
-		return err
-	}
-	path := filepath.Join(branchDir, "dump.sql")
-	return afero.WriteFile(fsys, path, []byte(out), 0644)
-}
-
-func stop(ctx context.Context) error {
+func stop(ctx context.Context, backup bool) error {
 	args := filters.NewArgs(
 		filters.Arg("label", "com.supabase.cli.project="+utils.Config.ProjectId),
 	)
@@ -86,12 +38,27 @@ func stop(ctx context.Context) error {
 	if err != nil {
 		return err
 	}
-	// Remove containers.
-	ids := make([]string, len(containers))
-	for i, c := range containers {
-		ids[i] = c.ID
+	// Gracefully shutdown containers
+	var ids []string
+	for _, c := range containers {
+		if c.State == "running" {
+			ids = append(ids, c.ID)
+		}
+	}
+	utils.WaitAll(ids, utils.DockerStop)
+	if _, err := utils.Docker.ContainersPrune(ctx, args); err != nil {
+		return err
+	}
+	// Remove named volumes
+	if !backup {
+		// TODO: label named volumes to use VolumesPrune for branch support
+		volumes := []string{utils.DbId, utils.StorageId}
+		utils.WaitAll(volumes, func(name string) {
+			if err := utils.Docker.VolumeRemove(ctx, name, true); err != nil {
+				fmt.Fprintln(os.Stderr, "failed to remove volume:", name, err)
+			}
+		})
 	}
-	utils.DockerRemoveContainers(ctx, ids)
 	// Remove networks.
 	_, err = utils.Docker.NetworksPrune(ctx, args)
 	return err
diff --git a/internal/stop/templates/dump.sh b/internal/stop/templates/dump.sh
deleted file mode 100755
index 0bf2bc9a..00000000
--- a/internal/stop/templates/dump.sh
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Explanation of special flags:
-#
-#   --schema-only     omit data like migration history, pgsodium key, etc.
-#   --exclude-schema  omit internal schemas as they are maintained by platform
-#   --no-comments     only object owner can set comment, omit to allow restore by non-superuser
-#   --extension '*'   prevents event triggers from being dumped, bash escaped with single quote
-pg_dump \
-    --column-inserts \
-    --quote-all-identifier \
-    --exclude-schema "$EXCLUDED_SCHEMAS" \
-    --extension '*' \
-    --no-comments \
-    --dbname "$DB_URL" \
-| sed 's/CREATE POLICY "cron_job_/-- CREATE POLICY "cron_job_/'
-
-# Reset session config generated by pg_dump
-echo "RESET ALL;"
diff --git a/internal/utils/docker.go b/internal/utils/docker.go
index 1752e62c..d5e3e718 100644
--- a/internal/utils/docker.go
+++ b/internal/utils/docker.go
@@ -14,10 +14,12 @@ import (
 	"sync"
 	"time"
 
+	"github.com/docker/cli/cli/compose/loader"
 	dockerConfig "github.com/docker/cli/cli/config"
 	"github.com/docker/cli/cli/streams"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/mount"
 	"github.com/docker/docker/client"
 	"github.com/docker/docker/errdefs"
 	"github.com/docker/docker/pkg/jsonmessage"
@@ -89,59 +91,34 @@ func DockerExec(ctx context.Context, container string, cmd []string) (io.Reader,
 // NOTE: There's a risk of data race with reads & writes from `DockerRun` and
 // reads from `DockerRemoveAll`, but since they're expected to be run on the
 // same thread, this is fine.
-var containers []string
-
-func DockerRun(
-	ctx context.Context,
-	name string,
-	config *container.Config,
-	hostConfig *container.HostConfig,
-) (io.Reader, error) {
-	config.Image = GetRegistryImageUrl(config.Image)
-	container, err := Docker.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
-	if err != nil {
-		return nil, err
-	}
-	containers = append(containers, name)
-
-	resp, err := Docker.ContainerAttach(ctx, container.ID, types.ContainerAttachOptions{Stream: true, Stdout: true, Stderr: true})
-	if err != nil {
-		return nil, err
-	}
-
-	if err := Docker.ContainerStart(ctx, container.ID, types.ContainerStartOptions{}); err != nil {
-		return nil, err
-	}
-
-	return resp.Reader, nil
-}
+var (
+	containers []string
+	volumes    []string
+)
 
-func DockerRemoveContainers(ctx context.Context, containers []string) {
+func WaitAll(containers []string, exec func(container string)) {
 	var wg sync.WaitGroup
-
 	for _, container := range containers {
 		wg.Add(1)
-
 		go func(container string) {
-			if err := Docker.ContainerRemove(ctx, container, types.ContainerRemoveOptions{
-				RemoveVolumes: true,
-				Force:         true,
-			}); err != nil {
-				// TODO: Handle errors
-				// fmt.Fprintln(os.Stderr, err)
-				_ = err
-			}
-
-			wg.Done()
+			defer wg.Done()
+			exec(container)
 		}(container)
 	}
-
 	wg.Wait()
 }
 
-func DockerRemoveAll(ctx context.Context, netId string) {
-	DockerRemoveContainers(ctx, containers)
-	_ = Docker.NetworkRemove(ctx, netId)
+func DockerRemoveAll(ctx context.Context) {
+	WaitAll(containers, func(container string) {
+		_ = Docker.ContainerRemove(ctx, container, types.ContainerRemoveOptions{
+			RemoveVolumes: true,
+			Force:         true,
+		})
+	})
+	WaitAll(volumes, func(name string) {
+		_ = Docker.VolumeRemove(ctx, name, true)
+	})
+	_ = Docker.NetworkRemove(ctx, NetId)
 }
 
 func DockerAddFile(ctx context.Context, container string, fileName string, content []byte) error {
@@ -292,7 +269,18 @@ func DockerStart(ctx context.Context, config container.Config, hostConfig contai
 	if err != nil {
 		return "", err
 	}
+	// Track container id for cleanup
 	containers = append(containers, resp.ID)
+	for _, bind := range hostConfig.Binds {
+		spec, err := loader.ParseVolume(bind)
+		if err != nil {
+			return "", err
+		}
+		// Track named volumes for cleanup
+		if len(spec.Source) > 0 && spec.Type == string(mount.TypeVolume) {
+			volumes = append(volumes, spec.Source)
+		}
+	}
 	// Run container in background
 	return resp.ID, Docker.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
 }
diff --git a/internal/utils/misc.go b/internal/utils/misc.go
index d0507e25..0e0c9e4a 100644
--- a/internal/utils/misc.go
+++ b/internal/utils/misc.go
@@ -16,7 +16,6 @@ import (
 	"path/filepath"
 	"regexp"
 	"runtime"
-	"strconv"
 	"strings"
 	"time"
 
@@ -519,14 +518,3 @@ func ValidateFunctionSlug(slug string) error {
 
 	return nil
 }
-
-func ShowStatus() {
-	fmt.Println(`
-         ` + Aqua("API URL") + `: http://localhost:` + strconv.FormatUint(uint64(Config.Api.Port), 10) + `
-          ` + Aqua("DB URL") + `: postgresql://postgres:postgres@localhost:` + strconv.FormatUint(uint64(Config.Db.Port), 10) + `/postgres
-      ` + Aqua("Studio URL") + `: http://localhost:` + strconv.FormatUint(uint64(Config.Studio.Port), 10) + `
-    ` + Aqua("Inbucket URL") + `: http://localhost:` + strconv.FormatUint(uint64(Config.Inbucket.Port), 10) + `
-      ` + Aqua("JWT secret") + `: ` + JWTSecret + `
-        ` + Aqua("anon key") + `: ` + AnonKey + `
-` + Aqua("service_role key") + `: ` + ServiceRoleKey)
-}


Test patch:
diff --git a/internal/start/start_test.go b/internal/start/start_test.go
index 3c27a0ce..38856913 100644
--- a/internal/start/start_test.go
+++ b/internal/start/start_test.go
@@ -143,6 +143,9 @@ func TestDatabaseStart(t *testing.T) {
 		// Start postgres
 		utils.DbId = "test-postgres"
 		utils.Config.Db.Port = 54322
+		gock.New(utils.Docker.DaemonHost()).
+			Get("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId).
+			Reply(http.StatusNotFound)
 		apitest.MockDockerStart(utils.Docker, imageUrl, utils.DbId)
 		// Start services
 		utils.KongId = "test-kong"
@@ -227,6 +230,10 @@ func TestDatabaseStart(t *testing.T) {
 		// Start postgres
 		utils.DbId = "test-postgres"
 		utils.Config.Db.Port = 54322
+		gock.New(utils.Docker.DaemonHost()).
+			Get("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId).
+			Reply(http.StatusOK).
+			JSON(types.Volume{})
 		apitest.MockDockerStart(utils.Docker, imageUrl, utils.DbId)
 		gock.New(utils.Docker.DaemonHost()).
 			Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.DbId + "/json").
@@ -237,20 +244,11 @@ func TestDatabaseStart(t *testing.T) {
 					Health:  &types.Health{Status: "healthy"},
 				},
 			}})
-		// Setup mock postgres
-		utils.GlobalsSql = "create schema public"
-		utils.InitialSchemaSql = "create schema private"
-		conn := pgtest.NewConn()
-		defer conn.Close(t)
-		conn.Query(utils.GlobalsSql).
-			Reply("CREATE SCHEMA").
-			Query(utils.InitialSchemaSql).
-			Reply("CREATE SCHEMA")
 		// Run test
 		exclude := ExcludableContainers()
 		exclude = append(exclude, "invalid", exclude[0])
 		err := utils.RunProgram(context.Background(), func(p utils.Program, ctx context.Context) error {
-			return run(p, context.Background(), fsys, exclude, conn.Intercept)
+			return run(p, context.Background(), fsys, exclude)
 		})
 		// Check error
 		assert.NoError(t, err)
diff --git a/internal/stop/stop_test.go b/internal/stop/stop_test.go
index 44d76117..daa23c33 100644
--- a/internal/stop/stop_test.go
+++ b/internal/stop/stop_test.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"errors"
 	"net/http"
-	"path/filepath"
 	"testing"
 
 	"github.com/docker/docker/api/types"
@@ -17,11 +16,10 @@ import (
 )
 
 func TestStopCommand(t *testing.T) {
-	t.Run("stops containers", func(t *testing.T) {
+	t.Run("stops containers with backup", func(t *testing.T) {
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, utils.WriteConfig(fsys, false))
-		require.NoError(t, afero.WriteFile(fsys, utils.CurrBranchPath, []byte("main"), 0644))
 		// Setup mock docker
 		require.NoError(t, apitest.MockDocker(utils.Docker))
 		defer gock.OffAll()
@@ -29,12 +27,16 @@ func TestStopCommand(t *testing.T) {
 			Get("/v" + utils.Docker.ClientVersion() + "/containers/json").
 			Reply(http.StatusOK).
 			JSON([]types.Container{})
+		gock.New(utils.Docker.DaemonHost()).
+			Post("/v" + utils.Docker.ClientVersion() + "/containers/prune").
+			Reply(http.StatusOK).
+			JSON(types.ContainersPruneReport{})
 		gock.New(utils.Docker.DaemonHost()).
 			Post("/v" + utils.Docker.ClientVersion() + "/networks/prune").
 			Reply(http.StatusOK).
 			JSON(types.NetworksPruneReport{})
 		// Run test
-		err := Run(context.Background(), false, fsys)
+		err := Run(context.Background(), true, fsys)
 		// Check error
 		assert.NoError(t, err)
 		assert.Empty(t, apitest.ListUnmatchedRequests())
@@ -53,23 +55,6 @@ func TestStopCommand(t *testing.T) {
 		assert.ErrorContains(t, err, "Missing config: open supabase/config.toml: file does not exist")
 	})
 
-	t.Run("throws error on backup 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() + "/images").
-			ReplyError(errors.New("network error"))
-		// Run test
-		err := Run(context.Background(), true, afero.NewReadOnlyFs(fsys))
-		// Check error
-		assert.ErrorContains(t, err, "network error")
-		assert.Empty(t, apitest.ListUnmatchedRequests())
-	})
-
 	t.Run("throws error on stop failure", func(t *testing.T) {
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
@@ -86,108 +71,82 @@ func TestStopCommand(t *testing.T) {
 		assert.ErrorContains(t, err, "request returned Service Unavailable for API route and version")
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
+}
 
-	t.Run("ignores permission error", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewMemMapFs()
-		require.NoError(t, utils.WriteConfig(fsys, false))
+func TestStopServices(t *testing.T) {
+	t.Run("stops all services", func(t *testing.T) {
+		containers := []types.Container{{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([]types.Container{})
+			JSON(containers)
+		gock.New(utils.Docker.DaemonHost()).
+			Post("/v" + utils.Docker.ClientVersion() + "/containers/" + containers[0].ID + "/stop").
+			Reply(http.StatusOK)
+		gock.New(utils.Docker.DaemonHost()).
+			Post("/v" + utils.Docker.ClientVersion() + "/containers/prune").
+			Reply(http.StatusOK).
+			JSON(types.ContainersPruneReport{})
 		gock.New(utils.Docker.DaemonHost()).
 			Post("/v" + utils.Docker.ClientVersion() + "/networks/prune").
 			Reply(http.StatusOK).
 			JSON(types.NetworksPruneReport{})
 		// Run test
-		err := Run(context.Background(), false, afero.NewReadOnlyFs(fsys))
+		err := stop(context.Background(), true)
 		// Check error
 		assert.NoError(t, err)
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
-}
 
-func TestStopServices(t *testing.T) {
-	t.Run("stops all services", func(t *testing.T) {
-		containers := []types.Container{{ID: "c1"}, {ID: "c2"}}
+	t.Run("removes data volumes", func(t *testing.T) {
+		utils.DbId = "test-db"
+		utils.StorageId = "test-storage"
 		// 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)
-		for _, c := range containers {
-			gock.New(utils.Docker.DaemonHost()).
-				Delete("/v" + utils.Docker.ClientVersion() + "/containers/" + c.ID).
-				Reply(http.StatusOK)
-		}
+			JSON([]types.Container{})
+		gock.New(utils.Docker.DaemonHost()).
+			Post("/v" + utils.Docker.ClientVersion() + "/containers/prune").
+			Reply(http.StatusOK).
+			JSON(types.ContainersPruneReport{})
+		gock.New(utils.Docker.DaemonHost()).
+			Delete("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.DbId).
+			Reply(http.StatusOK)
+		gock.New(utils.Docker.DaemonHost()).
+			Delete("/v" + utils.Docker.ClientVersion() + "/volumes/" + utils.StorageId).
+			Reply(http.StatusNotFound)
 		gock.New(utils.Docker.DaemonHost()).
 			Post("/v" + utils.Docker.ClientVersion() + "/networks/prune").
 			Reply(http.StatusOK).
 			JSON(types.NetworksPruneReport{})
 		// Run test
-		err := stop(context.Background())
+		err := stop(context.Background(), false)
 		// Check error
 		assert.NoError(t, err)
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 
-	t.Run("throws error on list failure", func(t *testing.T) {
+	t.Run("throws error on prune failure", func(t *testing.T) {
 		// 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 := stop(context.Background())
-		// Check error
-		assert.ErrorContains(t, err, "request returned Service Unavailable for API route and version")
-		assert.Empty(t, apitest.ListUnmatchedRequests())
-	})
-}
-
-func TestBackupDatabase(t *testing.T) {
-	const containerId = "test-db"
-	const dumped = "create schema public"
-	imageUrl := utils.GetRegistryImageUrl(utils.Pg15Image)
-
-	t.Run("backup main branch", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewMemMapFs()
-		// Setup mock docker
-		require.NoError(t, apitest.MockDocker(utils.Docker))
-		defer gock.OffAll()
-		apitest.MockDockerStart(utils.Docker, imageUrl, containerId)
-		require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, dumped))
-		// Run test
-		err := backupDatabase(context.Background(), fsys)
-		// Check error
-		assert.NoError(t, err)
-		assert.Empty(t, apitest.ListUnmatchedRequests())
-		path := filepath.Join(filepath.Dir(utils.CurrBranchPath), "main", "dump.sql")
-		// Check branch dumped
-		contents, err := afero.ReadFile(fsys, path)
-		assert.NoError(t, err)
-		assert.Equal(t, []byte(dumped), contents)
-	})
-
-	t.Run("throws error on permission denied", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := afero.NewReadOnlyFs(afero.NewMemMapFs())
-		// Setup mock docker
-		require.NoError(t, apitest.MockDocker(utils.Docker))
-		defer gock.OffAll()
-		apitest.MockDockerStart(utils.Docker, imageUrl, containerId)
-		require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, dumped))
+			Reply(http.StatusOK).
+			JSON([]types.Container{})
+		gock.New(utils.Docker.DaemonHost()).
+			Post("/v" + utils.Docker.ClientVersion() + "/containers/prune").
+			ReplyError(errors.New("network error"))
 		// Run test
-		err := backupDatabase(context.Background(), fsys)
+		err := stop(context.Background(), true)
 		// Check error
-		assert.ErrorContains(t, err, "operation not permitted")
+		assert.ErrorContains(t, err, "network error")
 		assert.Empty(t, apitest.ListUnmatchedRequests())
 	})
 }