From 2133eb5dff48927523096f76642ad22fe8c83e18 Mon Sep 17 00:00:00 2001 From: James Jackson Date: Mon, 16 Mar 2026 12:51:49 -0400 Subject: [PATCH 1/6] feat: add apple-container runtime support Add Apple Containers as an alternative runtime for Supabase CLI-managed services. Docker remains the default runtime, while apple-container can be selected anywhere the CLI manages the service lifecycle. The primary use case today is local development, but the runtime abstraction is not limited to that environment. --- cmd/root.go | 3 +- docs/supabase/start.md | 2 + docs/supabase/status.md | 2 + docs/supabase/stop.md | 4 +- internal/db/reset/reset.go | 112 +++- internal/db/reset/reset_test.go | 151 +++++ internal/db/start/start.go | 38 +- internal/db/start/start_test.go | 51 ++ internal/functions/serve/serve.go | 21 +- internal/functions/serve/serve_test.go | 22 + internal/start/start.go | 592 +++++++++++++------- internal/start/start_test.go | 242 ++++++++ internal/start/templates/kong.yml | 5 + internal/status/status.go | 101 +++- internal/status/status_test.go | 93 ++++ internal/stop/stop.go | 23 +- internal/utils/apple_container.go | 681 +++++++++++++++++++++++ internal/utils/apple_container_test.go | 198 +++++++ internal/utils/config.go | 29 + internal/utils/config_test.go | 62 +++ internal/utils/docker.go | 30 +- internal/utils/flags/config_path.go | 9 + internal/utils/flags/config_path_test.go | 51 ++ internal/utils/misc.go | 12 +- internal/utils/runtime.go | 459 +++++++++++++++ internal/utils/runtime_test.go | 49 ++ pkg/config/config.go | 185 +++--- pkg/config/config_test.go | 21 + pkg/config/templates/config.toml | 4 + 29 files changed, 2873 insertions(+), 379 deletions(-) create mode 100644 internal/utils/apple_container.go create mode 100644 internal/utils/apple_container_test.go create mode 100644 internal/utils/flags/config_path_test.go create mode 100644 internal/utils/runtime.go create mode 100644 internal/utils/runtime_test.go diff --git a/cmd/root.go b/cmd/root.go index b17b6a1fc5..1764b1d8a4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -203,7 +203,7 @@ func recoverAndExit() { !viper.GetBool("DEBUG") { utils.CmdSuggestion = utils.SuggestDebugFlag } - if e, ok := err.(*errors.Error); ok && len(utils.Version) == 0 { + if e, ok := err.(*errors.Error); ok && viper.GetBool("DEBUG") { fmt.Fprintln(os.Stderr, string(e.Stack())) } msg = err.Error() @@ -240,6 +240,7 @@ func init() { flags.String("workdir", "", "path to a Supabase project directory") flags.Bool("experimental", false, "enable experimental features") flags.String("network-id", "", "use the specified docker network instead of a generated one") + flags.String("runtime", "", "container runtime for local development (docker|apple-container)") flags.String("profile", "supabase", "use a specific profile for connecting to Supabase API") flags.VarP(&utils.OutputFormat, "output", "o", "output format of status variables") flags.Var(&utils.DNSResolver, "dns-resolver", "lookup domain names using the specified resolver") diff --git a/docs/supabase/start.md b/docs/supabase/start.md index a590610f11..42009ed5df 100644 --- a/docs/supabase/start.md +++ b/docs/supabase/start.md @@ -4,6 +4,8 @@ Starts the Supabase local development stack. Requires `supabase/config.toml` to be created in your current working directory by running `supabase init`. +Use `--runtime` to override the local container runtime for the current command. To make it persistent for the project, set `[local].runtime` in `supabase/config.toml`. + All service containers are started by default. You can exclude those not needed by passing in `-x` flag. To exclude multiple containers, either pass in a comma separated string, such as `-x gotrue,imgproxy`, or specify `-x` flag multiple times. > It is recommended to have at least 7GB of RAM to start all services. diff --git a/docs/supabase/status.md b/docs/supabase/status.md index 5db5c7ce83..f669119b78 100644 --- a/docs/supabase/status.md +++ b/docs/supabase/status.md @@ -4,4 +4,6 @@ Shows status of the Supabase local development stack. Requires the local development stack to be started by running `supabase start` or `supabase db start`. +The pretty output includes a runtime summary with the selected local runtime, project id, and tracked containers, networks, and volumes. + You can export the connection parameters for [initializing supabase-js](https://supabase.com/docs/reference/javascript/initializing) locally by specifying the `-o env` flag. Supported parameters include `JWT_SECRET`, `ANON_KEY`, and `SERVICE_ROLE_KEY`. diff --git a/docs/supabase/stop.md b/docs/supabase/stop.md index 870fa8603d..b5ea58da81 100644 --- a/docs/supabase/stop.md +++ b/docs/supabase/stop.md @@ -4,6 +4,6 @@ Stops the Supabase local development stack. Requires `supabase/config.toml` to be created in your current working directory by running `supabase init`. -All Docker resources are maintained across restarts. Use `--no-backup` flag to reset your local development data between restarts. +Local container resources are maintained across restarts for both the `docker` and `apple-container` runtimes. Use `--no-backup` flag to reset your local development data between restarts. -Use the `--all` flag to stop all local Supabase projects instances on the machine. Use with caution with `--no-backup` as it will delete all supabase local projects data. \ No newline at end of file +Use the `--all` flag to stop all local Supabase projects instances on the machine. Use with caution with `--no-backup` as it will delete all supabase local projects data. diff --git a/internal/db/reset/reset.go b/internal/db/reset/reset.go index d86f344975..1d8c99cc8d 100644 --- a/internal/db/reset/reset.go +++ b/internal/db/reset/reset.go @@ -5,6 +5,7 @@ import ( _ "embed" "fmt" "io" + "net" "os" "strconv" "strings" @@ -12,24 +13,39 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/containerd/errdefs" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" "github.com/go-errors/errors" "github.com/jackc/pgconn" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/start" + dbstart "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/migration/apply" "github.com/supabase/cli/internal/migration/down" "github.com/supabase/cli/internal/migration/list" "github.com/supabase/cli/internal/migration/repair" "github.com/supabase/cli/internal/seed/buckets" + stackstart "github.com/supabase/cli/internal/start" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/migration" ) +var ( + assertSupabaseDbIsRunning = utils.AssertSupabaseDbIsRunning + removeContainer = utils.RemoveContainer + removeVolume = utils.RemoveVolume + startContainer = utils.DockerStart + inspectContainer = utils.InspectContainer + restartContainer = utils.RestartContainer + waitForHealthyService = dbstart.WaitForHealthyService + waitForLocalDatabase = waitForDatabaseReady + waitForLocalAPI = waitForAPIReady + setupLocalDatabase = dbstart.SetupLocalDatabase + restartKong = stackstart.RestartKong + runBucketSeed = buckets.Run + seedBuckets = seedBucketsWithRetry +) + func Run(ctx context.Context, version string, last uint, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if len(version) > 0 { if _, err := strconv.Atoi(version); err != nil { @@ -54,7 +70,7 @@ func Run(ctx context.Context, version string, last uint, config pgconn.Config, f return resetRemote(ctx, version, config, fsys, options...) } // Config file is loaded before parsing --linked or --local flags - if err := utils.AssertSupabaseDbIsRunning(); err != nil { + if err := assertSupabaseDbIsRunning(); err != nil { return err } // Reset postgres database because extensions (pg_cron, pg_net) require postgres @@ -62,13 +78,30 @@ func Run(ctx context.Context, version string, last uint, config pgconn.Config, f return err } // Seed objects from supabase/buckets directory - if resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId); err == nil { - if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { - if err := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { + if _, err := inspectContainer(ctx, utils.StorageId); err == nil { + if shouldRefreshAPIAfterReset() { + // Kong caches upstream addresses; recreate it after the db container gets a new IP. + if err := restartKong(ctx, stackstart.KongDependencies{ + Gotrue: utils.Config.Auth.Enabled, + Rest: utils.Config.Api.Enabled, + Realtime: utils.Config.Realtime.Enabled, + Storage: utils.Config.Storage.Enabled, + Studio: utils.Config.Studio.Enabled, + Pgmeta: utils.Config.Studio.Enabled, + Edge: true, + Logflare: utils.Config.Analytics.Enabled, + Pooler: utils.Config.Db.Pooler.Enabled, + }); err != nil { + return err + } + if err := waitForLocalAPI(ctx, 30*time.Second); err != nil { return err } } - if err := buckets.Run(ctx, "", false, fsys); err != nil { + if err := waitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { + return err + } + if err := seedBuckets(ctx, fsys); err != nil { return err } } @@ -77,6 +110,10 @@ func Run(ctx context.Context, version string, last uint, config pgconn.Config, f return nil } +func shouldRefreshAPIAfterReset() bool { + return utils.UsesAppleContainerRuntime() && utils.Config.Api.Enabled +} + func resetDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { fmt.Fprintln(os.Stderr, "Resetting local database"+toLogMessage(version)) if utils.Config.Db.MajorVersion <= 14 { @@ -111,14 +148,14 @@ func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options } func resetDatabase15(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if err := utils.Docker.ContainerRemove(ctx, utils.DbId, container.RemoveOptions{Force: true}); err != nil { + if err := removeContainer(ctx, utils.DbId, true, true); err != nil { return errors.Errorf("failed to remove container: %w", err) } - if err := utils.Docker.VolumeRemove(ctx, utils.DbId, true); err != nil { + if err := removeVolume(ctx, utils.DbId, true); err != nil { return errors.Errorf("failed to remove volume: %w", err) } - config := start.NewContainerConfig() - hostConfig := start.NewHostConfig() + config := dbstart.NewContainerConfig() + hostConfig := dbstart.NewHostConfig() networkingConfig := network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ utils.NetId: { @@ -127,13 +164,16 @@ func resetDatabase15(ctx context.Context, version string, fsys afero.Fs, options }, } fmt.Fprintln(os.Stderr, "Recreating database...") - if _, err := utils.DockerStart(ctx, config, hostConfig, networkingConfig, utils.DbId); err != nil { + if _, err := startContainer(ctx, config, hostConfig, networkingConfig, utils.DbId); err != nil { return err } - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, utils.DbId); err != nil { + if err := waitForHealthyService(ctx, utils.Config.Db.HealthTimeout, utils.DbId); err != nil { return err } - if err := start.SetupLocalDatabase(ctx, version, fsys, os.Stderr, options...); err != nil { + if err := waitForLocalDatabase(ctx, utils.Config.Db.HealthTimeout, options...); err != nil { + return err + } + if err := setupLocalDatabase(ctx, version, fsys, os.Stderr, options...); err != nil { return err } fmt.Fprintln(os.Stderr, "Restarting containers...") @@ -146,7 +186,7 @@ func initDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error { return err } defer conn.Close(context.Background()) - return start.InitSchema14(ctx, conn) + return dbstart.InitSchema14(ctx, conn) } // Recreate postgres database by connecting to template1 @@ -193,7 +233,7 @@ func DisconnectClients(ctx context.Context, conn *pgx.Conn) error { } } // Wait for WAL senders to drop their replication slots - policy := start.NewBackoffPolicy(ctx, 10*time.Second) + policy := dbstart.NewBackoffPolicy(ctx, 10*time.Second) waitForDrop := func() error { var count int if err := conn.QueryRow(ctx, COUNT_REPLICATION_SLOTS).Scan(&count); err != nil { @@ -211,20 +251,50 @@ func RestartDatabase(ctx context.Context, w io.Writer) error { fmt.Fprintln(w, "Restarting containers...") // Some extensions must be manually restarted after pg_terminate_backend // Ref: https://github.com/citusdata/pg_cron/issues/99 - if err := utils.Docker.ContainerRestart(ctx, utils.DbId, container.StopOptions{}); err != nil { + if err := restartContainer(ctx, utils.DbId); err != nil { return errors.Errorf("failed to restart container: %w", err) } - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, utils.DbId); err != nil { + if err := waitForHealthyService(ctx, utils.Config.Db.HealthTimeout, utils.DbId); err != nil { return err } return restartServices(ctx) } +func waitForDatabaseReady(ctx context.Context, timeout time.Duration, options ...func(*pgx.ConnConfig)) error { + policy := dbstart.NewBackoffPolicy(ctx, timeout) + return backoff.Retry(func() error { + conn, err := utils.ConnectLocalPostgres(ctx, pgconn.Config{}, options...) + if err != nil { + return err + } + return conn.Close(ctx) + }, policy) +} + +func seedBucketsWithRetry(ctx context.Context, fsys afero.Fs) error { + policy := dbstart.NewBackoffPolicy(ctx, 30*time.Second) + return backoff.Retry(func() error { + return runBucketSeed(ctx, "", false, fsys) + }, policy) +} + +func waitForAPIReady(ctx context.Context, timeout time.Duration) error { + addr := net.JoinHostPort(utils.Config.Hostname, strconv.FormatUint(uint64(utils.Config.Api.Port), 10)) + policy := dbstart.NewBackoffPolicy(ctx, timeout) + return backoff.Retry(func() error { + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + return err + } + return conn.Close() + }, policy) +} + func restartServices(ctx context.Context) error { // No need to restart PostgREST because it automatically reconnects and listens for schema changes services := listServicesToRestart() result := utils.WaitAll(services, func(id string) error { - if err := utils.Docker.ContainerRestart(ctx, id, container.StopOptions{}); err != nil && !errdefs.IsNotFound(err) { + if err := restartContainer(ctx, id); err != nil && !errdefs.IsNotFound(err) { return errors.Errorf("failed to restart %s: %w", id, err) } return nil @@ -234,7 +304,7 @@ func restartServices(ctx context.Context) error { } func listServicesToRestart() []string { - return []string{utils.StorageId, utils.GotrueId, utils.RealtimeId, utils.PoolerId} + return []string{utils.StorageId, utils.GotrueId, utils.RealtimeId, utils.PoolerId, utils.KongId} } func resetRemote(ctx context.Context, version string, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { diff --git a/internal/db/reset/reset_test.go b/internal/db/reset/reset_test.go index 8bfad09b92..e95db397ee 100644 --- a/internal/db/reset/reset_test.go +++ b/internal/db/reset/reset_test.go @@ -5,16 +5,22 @@ import ( "errors" "io" "net/http" + "slices" + "sync" "testing" + "time" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" "github.com/h2non/gock" "github.com/jackc/pgconn" "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v4" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/start" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" @@ -35,6 +41,14 @@ func TestResetCommand(t *testing.T) { } t.Run("seeds storage after reset", func(t *testing.T) { + originalWaitForLocalDatabase := waitForLocalDatabase + t.Cleanup(func() { + waitForLocalDatabase = originalWaitForLocalDatabase + }) + waitForLocalDatabase = func(context.Context, time.Duration, ...func(*pgx.ConnConfig)) error { + return nil + } + utils.DbId = "test-reset" utils.Config.Db.MajorVersion = 15 // Setup in-memory fs @@ -70,6 +84,7 @@ func TestResetCommand(t *testing.T) { utils.GotrueId = "test-auth" utils.RealtimeId = "test-realtime" utils.PoolerId = "test-pooler" + utils.KongId = "test-kong" for _, container := range listServicesToRestart() { gock.New(utils.Docker.DaemonHost()). Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). @@ -77,6 +92,7 @@ func TestResetCommand(t *testing.T) { } // Seeds storage gock.New(utils.Docker.DaemonHost()). + Persist(). Get("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.StorageId + "/json"). Reply(http.StatusOK). JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ @@ -152,6 +168,136 @@ func TestResetCommand(t *testing.T) { assert.ErrorContains(t, err, "network error") assert.Empty(t, apitest.ListUnmatchedRequests()) }) + + t.Run("uses runtime helpers on apple container runtime", func(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalAPIEnabled := utils.Config.Api.Enabled + originalAssertRunning := assertSupabaseDbIsRunning + originalRemoveContainer := removeContainer + originalRemoveVolume := removeVolume + originalStartContainer := startContainer + originalInspectContainer := inspectContainer + originalRestartContainer := restartContainer + originalWaitForHealthyService := waitForHealthyService + originalWaitForLocalDatabase := waitForLocalDatabase + originalWaitForLocalAPI := waitForLocalAPI + originalSetupLocalDatabase := setupLocalDatabase + originalRestartKong := restartKong + originalRunBucketSeed := runBucketSeed + originalDbID := utils.DbId + originalStorageID := utils.StorageId + originalGotrueID := utils.GotrueId + originalRealtimeID := utils.RealtimeId + originalPoolerID := utils.PoolerId + originalKongID := utils.KongId + + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + utils.Config.Api.Enabled = originalAPIEnabled + assertSupabaseDbIsRunning = originalAssertRunning + removeContainer = originalRemoveContainer + removeVolume = originalRemoveVolume + startContainer = originalStartContainer + inspectContainer = originalInspectContainer + restartContainer = originalRestartContainer + waitForHealthyService = originalWaitForHealthyService + waitForLocalDatabase = originalWaitForLocalDatabase + waitForLocalAPI = originalWaitForLocalAPI + setupLocalDatabase = originalSetupLocalDatabase + restartKong = originalRestartKong + runBucketSeed = originalRunBucketSeed + utils.DbId = originalDbID + utils.StorageId = originalStorageID + utils.GotrueId = originalGotrueID + utils.RealtimeId = originalRealtimeID + utils.PoolerId = originalPoolerID + utils.KongId = originalKongID + }) + + utils.Config.Local.Runtime = "apple-container" + utils.Config.Db.MajorVersion = 15 + utils.Config.Api.Enabled = true + utils.DbId = "test-reset" + utils.StorageId = "test-storage" + utils.GotrueId = "test-auth" + utils.RealtimeId = "test-realtime" + utils.PoolerId = "test-pooler" + utils.KongId = "test-kong" + + fsys := afero.NewMemMapFs() + + var removedContainers []string + var removedVolumes []string + var startedContainers []string + var restartedContainers []string + var waited []string + var mu sync.Mutex + restartedKong := false + bucketSeeded := false + + assertSupabaseDbIsRunning = func() error { return nil } + removeContainer = func(_ context.Context, containerID string, removeVolumes, force bool) error { + assert.True(t, removeVolumes) + assert.True(t, force) + removedContainers = append(removedContainers, containerID) + return nil + } + removeVolume = func(_ context.Context, volumeName string, force bool) error { + assert.True(t, force) + removedVolumes = append(removedVolumes, volumeName) + return nil + } + startContainer = func(_ context.Context, _ container.Config, _ container.HostConfig, _ network.NetworkingConfig, containerName string) (string, error) { + startedContainers = append(startedContainers, containerName) + return containerName, nil + } + inspectContainer = func(_ context.Context, containerID string) (utils.ContainerInfo, error) { + if containerID == utils.StorageId || containerID == utils.KongId { + return utils.ContainerInfo{ID: containerID, Running: true}, nil + } + return utils.ContainerInfo{}, errors.New("unexpected inspect") + } + restartContainer = func(_ context.Context, containerID string) error { + mu.Lock() + restartedContainers = append(restartedContainers, containerID) + mu.Unlock() + return nil + } + waitForHealthyService = func(_ context.Context, _ time.Duration, started ...string) error { + waited = append(waited, started...) + return nil + } + waitForLocalDatabase = func(_ context.Context, _ time.Duration, _ ...func(*pgx.ConnConfig)) error { + return nil + } + waitForLocalAPI = func(_ context.Context, _ time.Duration) error { + return nil + } + setupLocalDatabase = func(_ context.Context, version string, _ afero.Fs, _ io.Writer, _ ...func(*pgx.ConnConfig)) error { + assert.Empty(t, version) + return nil + } + restartKong = func(_ context.Context, deps start.KongDependencies) error { + _ = deps + restartedKong = true + return nil + } + runBucketSeed = func(_ context.Context, _ string, _ bool, _ afero.Fs) error { + bucketSeeded = true + return nil + } + + err := Run(context.Background(), "", 0, dbConfig, fsys) + + require.NoError(t, err) + assert.Equal(t, []string{utils.DbId}, removedContainers) + assert.Equal(t, []string{utils.DbId}, removedVolumes) + assert.Equal(t, []string{utils.DbId}, startedContainers) + assert.True(t, bucketSeeded) + assert.True(t, restartedKong) + assert.True(t, slices.Contains(waited, utils.DbId)) + assert.ElementsMatch(t, []string{utils.StorageId, utils.GotrueId, utils.RealtimeId, utils.PoolerId, utils.KongId}, restartedContainers) + }) } func TestInitDatabase(t *testing.T) { @@ -304,6 +450,7 @@ func TestRestartDatabase(t *testing.T) { utils.GotrueId = "test-auth" utils.RealtimeId = "test-realtime" utils.PoolerId = "test-pooler" + utils.KongId = "test-kong" for _, container := range listServicesToRestart() { gock.New(utils.Docker.DaemonHost()). Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). @@ -339,6 +486,7 @@ func TestRestartDatabase(t *testing.T) { utils.GotrueId = "test-auth" utils.RealtimeId = "test-realtime" utils.PoolerId = "test-pooler" + utils.KongId = "test-kong" for _, container := range []string{utils.StorageId, utils.GotrueId, utils.RealtimeId} { gock.New(utils.Docker.DaemonHost()). Post("/v" + utils.Docker.ClientVersion() + "/containers/" + container + "/restart"). @@ -347,6 +495,9 @@ func TestRestartDatabase(t *testing.T) { gock.New(utils.Docker.DaemonHost()). Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.PoolerId + "/restart"). Reply(http.StatusNotFound) + gock.New(utils.Docker.DaemonHost()). + Post("/v" + utils.Docker.ClientVersion() + "/containers/" + utils.KongId + "/restart"). + Reply(http.StatusOK) // Run test err := RestartDatabase(context.Background(), io.Discard) // Check error diff --git a/internal/db/start/start.go b/internal/db/start/start.go index a30619398f..9c8097605f 100644 --- a/internal/db/start/start.go +++ b/internal/db/start/start.go @@ -12,7 +12,6 @@ import ( "time" "github.com/cenkalti/backoff/v4" - "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" "github.com/docker/go-connections/nat" @@ -38,9 +37,18 @@ var ( //go:embed templates/_supabase.sql _supabaseSchema string //go:embed templates/restore.sh - restoreScript string + restoreScript string + resolveContainerIP = utils.GetContainerIP ) +func runtimePostgresConfig() string { + settings := utils.Config.Db.Settings.ToPostgresConfig() + if utils.UsesAppleContainerRuntime() { + settings += "\ndata_directory = '/var/lib/postgresql/data/pgdata'\n" + } + return settings +} + func Run(ctx context.Context, fromBackup string, fsys afero.Fs) error { if err := flags.LoadConfig(fsys); err != nil { return err @@ -79,6 +87,9 @@ func NewContainerConfig(args ...string) container.Config { } else if i := strings.IndexByte(utils.Config.Db.Image, ':'); config.VersionCompare(utils.Config.Db.Image[i+1:], "15.8.1.005") < 0 { env = append(env, "POSTGRES_INITDB_ARGS=--lc-collate=C.UTF-8") } + if utils.UsesAppleContainerRuntime() { + env = append(env, "PGDATA=/var/lib/postgresql/data/pgdata") + } config := container.Config{ Image: utils.Config.Db.Image, Env: env, @@ -99,7 +110,7 @@ docker-entrypoint.sh postgres -D /etc/postgresql ` + strings.Join(args, " ") + ` EOF ` + utils.Config.Db.RootKey.Value + ` EOF -` + utils.Config.Db.Settings.ToPostgresConfig() + ` +` + runtimePostgresConfig() + ` EOF`}, } if utils.Config.Db.MajorVersion <= 14 { @@ -109,7 +120,7 @@ cat <<'EOF' >> /etc/postgresql/postgresql.conf && \ docker-entrypoint.sh postgres -D /etc/postgresql ` + strings.Join(args, " ") + ` ` + _supabaseSchema + ` EOF -` + utils.Config.Db.Settings.ToPostgresConfig() + ` +` + runtimePostgresConfig() + ` EOF`} } return config @@ -154,7 +165,7 @@ EOF EOF ` + utils.Config.Db.RootKey.Value + ` EOF -` + utils.Config.Db.Settings.ToPostgresConfig() + ` +` + runtimePostgresConfig() + ` cron.launch_active_jobs = off EOF`} if !filepath.IsAbs(fromBackup) { @@ -163,8 +174,8 @@ EOF`} hostConfig.Binds = append(hostConfig.Binds, utils.ToDockerPath(fromBackup)+":/etc/backup.sql:ro") } // Creating volume will not override existing volume, so we must inspect explicitly - _, err := utils.Docker.VolumeInspect(ctx, utils.DbId) - utils.NoBackupVolume = errdefs.IsNotFound(err) + exists, err := utils.VolumeExists(ctx, utils.DbId) + utils.NoBackupVolume = err == nil && !exists if utils.NoBackupVolume { fmt.Fprintln(w, "Starting database...") } else if len(fromBackup) > 0 { @@ -189,6 +200,13 @@ EOF`} return initCurrentBranch(fsys) } +func resolveDatabaseHost(ctx context.Context, host string) (string, error) { + if !utils.UsesAppleContainerRuntime() || host != utils.DbId { + return host, nil + } + return resolveContainerIP(ctx, utils.DbId, utils.NetId) +} + func NewBackoffPolicy(ctx context.Context, timeout time.Duration) backoff.BackOff { policy := backoff.WithMaxRetries( backoff.NewConstantBackOff(time.Second), @@ -362,7 +380,11 @@ func SetupLocalDatabase(ctx context.Context, version string, fsys afero.Fs, w io return err } defer conn.Close(context.Background()) - if err := SetupDatabase(ctx, conn, utils.DbId, w, fsys); err != nil { + host, err := resolveDatabaseHost(ctx, utils.DbId) + if err != nil { + return err + } + if err := SetupDatabase(ctx, conn, host, w, fsys); err != nil { return err } if err := apply.MigrateAndSeed(ctx, version, conn, fsys); err != nil { diff --git a/internal/db/start/start_test.go b/internal/db/start/start_test.go index 39f23d8bad..9c6814edc8 100644 --- a/internal/db/start/start_test.go +++ b/internal/db/start/start_test.go @@ -309,6 +309,57 @@ func TestSetupDatabase(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) } + +func TestResolveDatabaseHost(t *testing.T) { + t.Run("returns container ip on apple runtime", func(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalDbId := utils.DbId + originalNetId := utils.NetId + originalResolve := resolveContainerIP + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + utils.DbId = originalDbId + utils.NetId = originalNetId + resolveContainerIP = originalResolve + }) + utils.Config.Local.Runtime = "apple-container" + utils.DbId = "supabase-db-test" + utils.NetId = "supabase-network-test" + resolveContainerIP = func(ctx context.Context, containerId, networkName string) (string, error) { + assert.Equal(t, utils.DbId, containerId) + assert.Equal(t, utils.NetId, networkName) + return "192.168.64.2", nil + } + + host, err := resolveDatabaseHost(context.Background(), utils.DbId) + + require.NoError(t, err) + assert.Equal(t, "192.168.64.2", host) + }) + + t.Run("keeps docker alias on non apple runtime", func(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalDbId := utils.DbId + originalResolve := resolveContainerIP + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + utils.DbId = originalDbId + resolveContainerIP = originalResolve + }) + utils.Config.Local.Runtime = "docker" + utils.DbId = "supabase_db_test" + resolveContainerIP = func(ctx context.Context, containerId, networkName string) (string, error) { + t.Fatal("resolveContainerIP should not be called") + return "", nil + } + + host, err := resolveDatabaseHost(context.Background(), utils.DbId) + + require.NoError(t, err) + assert.Equal(t, utils.DbId, host) + }) +} + func TestStartDatabaseWithCustomSettings(t *testing.T) { t.Run("starts database with custom MaxConnections", func(t *testing.T) { // Setup diff --git a/internal/functions/serve/serve.go b/internal/functions/serve/serve.go index ba3346413e..cd308cf1aa 100644 --- a/internal/functions/serve/serve.go +++ b/internal/functions/serve/serve.go @@ -109,17 +109,21 @@ func restartEdgeRuntime(ctx context.Context, envFilePath string, noVerifyJWT *bo return err } // 2. Remove existing container. - _ = utils.Docker.ContainerRemove(ctx, utils.EdgeRuntimeId, container.RemoveOptions{ - RemoveVolumes: true, - Force: true, - }) - // Use network alias because Deno cannot resolve `_` in hostname - dbUrl := fmt.Sprintf("postgresql://postgres:postgres@%s:5432/postgres", utils.DbAliases[0]) + _ = utils.RemoveContainer(ctx, utils.EdgeRuntimeId, true, true) + dbHost := utils.RuntimeServiceHost(utils.DbAliases[0], utils.DbId) + dbUrl := fmt.Sprintf("postgresql://postgres:postgres@%s:5432/postgres", dbHost) // 3. Serve and log to console fmt.Fprintln(os.Stderr, "Setting up Edge Functions runtime...") return ServeFunctions(ctx, envFilePath, noVerifyJWT, importMapPath, dbUrl, runtimeOption, fsys) } +func edgeRuntimeWorkingDir(cwd string) string { + if utils.UsesAppleContainerRuntime() { + return "/root" + } + return utils.ToDockerPath(cwd) +} + func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, dbUrl string, runtimeOption RuntimeOption, fsys afero.Fs) error { // 1. Parse custom env file env, err := parseEnvFile(envFilePath, fsys) @@ -127,8 +131,9 @@ func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, return err } jwks, _ := utils.Config.Auth.ResolveJWKS(ctx) + kongHost := utils.RuntimeServiceHost(utils.KongAliases[0], utils.KongId) env = append(env, - fmt.Sprintf("SUPABASE_URL=http://%s:8000", utils.KongAliases[0]), + fmt.Sprintf("SUPABASE_URL=http://%s:8000", kongHost), "SUPABASE_ANON_KEY="+utils.Config.Auth.AnonKey.Value, "SUPABASE_SERVICE_ROLE_KEY="+utils.Config.Auth.ServiceRoleKey.Value, "SUPABASE_DB_URL="+dbUrl, @@ -208,7 +213,7 @@ EOF Env: env, Entrypoint: entrypoint, ExposedPorts: exposedPorts, - WorkingDir: utils.ToDockerPath(cwd), + WorkingDir: edgeRuntimeWorkingDir(cwd), // No tcp health check because edge runtime logs them as client connection error }, container.HostConfig{ diff --git a/internal/functions/serve/serve_test.go b/internal/functions/serve/serve_test.go index 38b7f420ce..1f17abe39a 100644 --- a/internal/functions/serve/serve_test.go +++ b/internal/functions/serve/serve_test.go @@ -171,3 +171,25 @@ func TestServeFunctions(t *testing.T) { assert.Equal(t, `{"hello":{"verifyJWT":true,"entrypointPath":"testdata/functions/hello/index.ts","staticFiles":["testdata/image.png"]}}`, configString) }) } + +func TestEdgeRuntimeWorkingDir(t *testing.T) { + t.Run("uses in-container working dir on apple runtime", func(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + }) + utils.Config.Local.Runtime = "apple-container" + + assert.Equal(t, "/root", edgeRuntimeWorkingDir("/Users/james/project")) + }) + + t.Run("uses docker path on docker runtime", func(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + }) + utils.Config.Local.Runtime = "docker" + + assert.Equal(t, "/Users/james/project", edgeRuntimeWorkingDir("/Users/james/project")) + }) +} diff --git a/internal/start/start.go b/internal/start/start.go index d18d72511a..d55446d989 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -20,6 +20,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/compose-spec/compose-go/v2/types" + "github.com/containerd/errdefs" "github.com/docker/cli/cli/command" dockerFlags "github.com/docker/cli/cli/flags" "github.com/docker/compose/v2/pkg/api" @@ -61,6 +62,9 @@ func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignore _ = services.CheckVersions(ctx, fsys) } } + if err := reconcileStaleProjectContainers(ctx, utils.Config.ProjectId); err != nil { + return err + } dbConfig := pgconn.Config{ Host: utils.DbId, @@ -81,25 +85,38 @@ func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignore } fmt.Fprintf(os.Stderr, "Started %s local development setup.\n\n", utils.Aqua("supabase")) - status.PrettyPrint(os.Stdout, excludedContainers...) + status.PrettyPrint(ctx, os.Stdout, excludedContainers...) printSecurityNotice() return nil } type kongConfig struct { - GotrueId string - RestId string - RealtimeId string - StorageId string - StudioId string - PgmetaId string - EdgeRuntimeId string - LogflareId string - PoolerId string - ApiHost string - ApiPort uint16 - BearerToken string - QueryToken string + GotrueId string + RestId string + RealtimeId string + RealtimeTenantId string + StorageId string + StudioId string + PgmetaId string + EdgeRuntimeId string + LogflareId string + PoolerId string + ApiHost string + ApiPort uint16 + BearerToken string + QueryToken string +} + +type KongDependencies struct { + Gotrue bool + Rest bool + Realtime bool + Storage bool + Studio bool + Pgmeta bool + Edge bool + Logflare bool + Pooler bool } var ( @@ -152,6 +169,10 @@ var ( var serviceTimeout = 30 * time.Second +var resolveContainerIP = utils.GetContainerIP +var listProjectContainers = utils.ListProjectContainers +var removeProjectContainer = utils.RemoveContainer + // RetryClient wraps a Docker client to add retry logic for image pulls type RetryClient struct { *client.Client @@ -168,6 +189,241 @@ func isPermanentError(err error) bool { return true } +func reconcileStaleProjectContainers(ctx context.Context, projectId string) error { + containers, err := listProjectContainers(ctx, projectId, true) + if err != nil { + return errors.Errorf("failed to list project containers: %w", err) + } + for _, item := range containers { + if item.Running { + continue + } + if err := removeProjectContainer(ctx, item.ID, true, true); err != nil { + return errors.Errorf("failed to remove stale container %s: %w", item.ID, err) + } + } + return nil +} + +func runtimeContainerHost(ctx context.Context, containerId string, resolve bool) (string, error) { + if !utils.UsesAppleContainerRuntime() || !resolve { + return containerId, nil + } + return resolveContainerIP(ctx, containerId, utils.NetId) +} + +func runtimeContainerURL(ctx context.Context, containerId string, port uint16, resolve bool) (string, error) { + host, err := runtimeContainerHost(ctx, containerId, resolve) + if err != nil { + return "", err + } + return fmt.Sprintf("http://%s", net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))), nil +} + +func buildKongConfig(ctx context.Context, deps KongDependencies) (kongConfig, error) { + gotrueHost, err := runtimeContainerHost(ctx, utils.GotrueId, deps.Gotrue) + if err != nil { + return kongConfig{}, err + } + restHost, err := runtimeContainerHost(ctx, utils.RestId, deps.Rest) + if err != nil { + return kongConfig{}, err + } + realtimeHost, err := runtimeContainerHost(ctx, utils.RealtimeId, deps.Realtime) + if err != nil { + return kongConfig{}, err + } + storageHost, err := runtimeContainerHost(ctx, utils.StorageId, deps.Storage) + if err != nil { + return kongConfig{}, err + } + studioHost, err := runtimeContainerHost(ctx, utils.StudioId, deps.Studio) + if err != nil { + return kongConfig{}, err + } + pgmetaHost, err := runtimeContainerHost(ctx, utils.PgmetaId, deps.Pgmeta) + if err != nil { + return kongConfig{}, err + } + edgeHost, err := runtimeContainerHost(ctx, utils.EdgeRuntimeId, deps.Edge) + if err != nil { + return kongConfig{}, err + } + logflareHost, err := runtimeContainerHost(ctx, utils.LogflareId, deps.Logflare) + if err != nil { + return kongConfig{}, err + } + poolerHost, err := runtimeContainerHost(ctx, utils.PoolerId, deps.Pooler) + if err != nil { + return kongConfig{}, err + } + return kongConfig{ + GotrueId: gotrueHost, + RestId: restHost, + RealtimeId: realtimeHost, + RealtimeTenantId: utils.Config.Realtime.TenantId, + StorageId: storageHost, + StudioId: studioHost, + PgmetaId: pgmetaHost, + EdgeRuntimeId: edgeHost, + LogflareId: logflareHost, + PoolerId: poolerHost, + ApiHost: utils.Config.Hostname, + ApiPort: utils.Config.Api.Port, + BearerToken: fmt.Sprintf( + `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '%s' and 'Bearer %s') or (headers.apikey == '%s' and 'Bearer %s') or headers.apikey)`, + utils.Config.Auth.SecretKey.Value, + utils.Config.Auth.ServiceRoleKey.Value, + utils.Config.Auth.PublishableKey.Value, + utils.Config.Auth.AnonKey.Value, + ), + QueryToken: fmt.Sprintf( + `$((query_params.apikey == '%s' and '%s') or (query_params.apikey == '%s' and '%s') or query_params.apikey)`, + utils.Config.Auth.SecretKey.Value, + utils.Config.Auth.ServiceRoleKey.Value, + utils.Config.Auth.PublishableKey.Value, + utils.Config.Auth.AnonKey.Value, + ), + }, nil +} + +func startKong(ctx context.Context, deps KongDependencies) error { + var kongConfigBuf bytes.Buffer + kongConfig, err := buildKongConfig(ctx, deps) + if err != nil { + return err + } + if err := kongConfigTemplate.Option("missingkey=error").Execute(&kongConfigBuf, kongConfig); err != nil { + return errors.Errorf("failed to exec template: %w", err) + } + + binds := []string{} + for id, tmpl := range utils.Config.Auth.Email.Template { + if len(tmpl.ContentPath) == 0 { + continue + } + hostPath := tmpl.ContentPath + if !filepath.IsAbs(tmpl.ContentPath) { + var err error + hostPath, err = filepath.Abs(hostPath) + if err != nil { + return errors.Errorf("failed to resolve absolute path: %w", err) + } + } + dockerPath := path.Join(nginxEmailTemplateDir, id+filepath.Ext(hostPath)) + binds = append(binds, fmt.Sprintf("%s:%s:rw", hostPath, dockerPath)) + } + + dockerPort := uint16(8000) + if utils.Config.Api.Tls.Enabled { + dockerPort = 8443 + } + _, err = utils.DockerStart( + ctx, + container.Config{ + Image: utils.Config.Api.KongImage, + Env: []string{ + "KONG_DATABASE=off", + "KONG_DECLARATIVE_CONFIG=/home/kong/kong.yml", + "KONG_DNS_ORDER=LAST,A,CNAME", // https://github.com/supabase/cli/issues/14 + "KONG_PLUGINS=request-transformer,cors", + fmt.Sprintf("KONG_PORT_MAPS=%d:8000", utils.Config.Api.Port), + // Need to increase the nginx buffers in kong to avoid it rejecting the rather + // sizeable response headers azure can generate + // Ref: https://github.com/Kong/kong/issues/3974#issuecomment-482105126 + "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE=160k", + "KONG_NGINX_PROXY_PROXY_BUFFERS=64 160k", + "KONG_NGINX_WORKER_PROCESSES=1", + "KONG_SSL_CERT=/home/kong/localhost.crt", + "KONG_SSL_CERT_KEY=/home/kong/localhost.key", + }, + Entrypoint: []string{"sh", "-c", `cat <<'EOF' > /home/kong/kong.yml && \ +cat <<'EOF' > /home/kong/custom_nginx.template && \ +cat <<'EOF' > /home/kong/localhost.crt && \ +cat <<'EOF' > /home/kong/localhost.key && \ +./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template +` + kongConfigBuf.String() + ` +EOF +` + nginxConfigEmbed + ` +EOF +` + string(utils.Config.Api.Tls.CertContent) + ` +EOF +` + string(utils.Config.Api.Tls.KeyContent) + ` +EOF +`}, + ExposedPorts: nat.PortSet{ + "8000/tcp": {}, + "8443/tcp": {}, + nat.Port(fmt.Sprintf("%d/tcp", nginxTemplateServerPort)): {}, + }, + }, + container.HostConfig{ + Binds: binds, + PortBindings: nat.PortMap{nat.Port(fmt.Sprintf("%d/tcp", dockerPort)): []nat.PortBinding{{ + HostPort: strconv.FormatUint(uint64(utils.Config.Api.Port), 10), + }}}, + RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}, + }, + network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + utils.NetId: { + Aliases: utils.KongAliases, + }, + }, + }, + utils.KongId, + ) + return err +} + +func RestartKong(ctx context.Context, deps KongDependencies) error { + if err := utils.RemoveContainer(ctx, utils.KongId, true, true); err != nil && !errdefs.IsNotFound(err) { + return errors.Errorf("failed to remove kong container: %w", err) + } + return startKong(ctx, deps) +} + +func buildStudioEnv(ctx context.Context, workdir string, dbConfig pgconn.Config, snippetsManagementFolder string, isKongEnabled, isPgmetaEnabled, isLogflareEnabled bool) ([]string, error) { + pgmetaURL, err := runtimeContainerURL(ctx, utils.PgmetaId, 8080, isPgmetaEnabled) + if err != nil { + return nil, err + } + supabaseURL, err := runtimeContainerURL(ctx, utils.KongId, 8000, isKongEnabled) + if err != nil { + return nil, err + } + logflareURL, err := runtimeContainerURL(ctx, utils.LogflareId, 4000, isLogflareEnabled) + if err != nil { + return nil, err + } + return []string{ + "CURRENT_CLI_VERSION=" + utils.Version, + "STUDIO_PG_META_URL=" + pgmetaURL, + "POSTGRES_HOST=" + dbConfig.Host, + fmt.Sprintf("POSTGRES_PORT=%d", dbConfig.Port), + "POSTGRES_DB=" + dbConfig.Database, + "POSTGRES_PASSWORD=" + dbConfig.Password, + "SUPABASE_URL=" + supabaseURL, + "SUPABASE_PUBLIC_URL=" + utils.Config.Studio.ApiUrl, + "AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, + "SUPABASE_ANON_KEY=" + utils.Config.Auth.AnonKey.Value, + "SUPABASE_SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey.Value, + "LOGFLARE_PRIVATE_ACCESS_TOKEN=" + utils.Config.Analytics.ApiKey, + "OPENAI_API_KEY=" + utils.Config.Studio.OpenaiApiKey.Value, + "PGRST_DB_SCHEMAS=" + strings.Join(utils.Config.Api.Schemas, ","), + "PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","), + fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows), + "LOGFLARE_URL=" + logflareURL, + fmt.Sprintf("NEXT_PUBLIC_ENABLE_LOGS=%v", utils.Config.Analytics.Enabled), + fmt.Sprintf("NEXT_ANALYTICS_BACKEND_PROVIDER=%v", utils.Config.Analytics.Backend), + "EDGE_FUNCTIONS_MANAGEMENT_FOLDER=" + utils.ToDockerPath(filepath.Join(workdir, utils.FunctionsDir)), + "SNIPPETS_MANAGEMENT_FOLDER=" + snippetsManagementFolder, + // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 + "HOSTNAME=0.0.0.0", + "POSTGRES_USER_READ_WRITE=postgres", + }, nil +} + // ImagePull wraps the Docker client's ImagePull with retry logic and registry auth func (cli *RetryClient) ImagePull(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error) { if len(options.RegistryAuth) == 0 { @@ -199,6 +455,9 @@ func (cli *RetryClient) ImageInspect(ctx context.Context, refStr string, options // pullImagesUsingCompose pulls all required images using docker-compose service func pullImagesUsingCompose(ctx context.Context, project types.Project) error { + if utils.UsesAppleContainerRuntime() { + return nil + } // Create Docker CLI cli, err := command.NewDockerCli() if err != nil { @@ -219,6 +478,13 @@ func run(ctx context.Context, fsys afero.Fs, excludedContainers []string, dbConf for _, name := range excludedContainers { excluded[name] = true } + if utils.UsesAppleContainerRuntime() { + if !excluded[utils.ShortContainerImageName(utils.Config.Analytics.Image)] || !excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "apple-container runtime does not support analytics yet; skipping logflare and vector.") + excluded[utils.ShortContainerImageName(utils.Config.Analytics.Image)] = true + excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] = true + } + } notExcluded := func(sc types.ServiceConfig) bool { val, ok := excluded[sc.Name] return !val || !ok @@ -243,13 +509,31 @@ func run(ctx context.Context, fsys afero.Fs, excludedContainers []string, dbConf if err := start.StartDatabase(ctx, "", fsys, os.Stderr, options...); err != nil { return err } + if utils.UsesAppleContainerRuntime() { + ip, err := utils.GetContainerIP(ctx, utils.DbId, utils.NetId) + if err != nil { + return err + } + dbConfig.Host = ip + } } var started []string + isKongEnabled := !isContainerExcluded(utils.Config.Api.KongImage, excluded) + isAuthEnabled := utils.Config.Auth.Enabled && !isContainerExcluded(utils.Config.Auth.Image, excluded) + isInbucketEnabled := utils.Config.Inbucket.Enabled && !isContainerExcluded(utils.Config.Inbucket.Image, excluded) + isRealtimeEnabled := utils.Config.Realtime.Enabled && !isContainerExcluded(utils.Config.Realtime.Image, excluded) + isRestEnabled := utils.Config.Api.Enabled && !isContainerExcluded(utils.Config.Api.Image, excluded) isStorageEnabled := utils.Config.Storage.Enabled && !isContainerExcluded(utils.Config.Storage.Image, excluded) isImgProxyEnabled := utils.Config.Storage.ImageTransformation != nil && utils.Config.Storage.ImageTransformation.Enabled && !isContainerExcluded(utils.Config.Storage.ImgProxyImage, excluded) isS3ProtocolEnabled := utils.Config.Storage.S3Protocol != nil && utils.Config.Storage.S3Protocol.Enabled + isEdgeRuntimeEnabled := utils.Config.EdgeRuntime.Enabled && !isContainerExcluded(utils.Config.EdgeRuntime.Image, excluded) + isPgmetaEnabled := utils.Config.Studio.Enabled && !isContainerExcluded(utils.Config.Studio.PgmetaImage, excluded) + isStudioEnabled := utils.Config.Studio.Enabled && !isContainerExcluded(utils.Config.Studio.Image, excluded) + isLogflareEnabled := utils.Config.Analytics.Enabled && !isContainerExcluded(utils.Config.Analytics.Image, excluded) + isVectorEnabled := utils.Config.Analytics.Enabled && !isContainerExcluded(utils.Config.Analytics.VectorImage, excluded) + isPoolerEnabled := utils.Config.Db.Pooler.Enabled && !isContainerExcluded(utils.Config.Db.Pooler.Image, excluded) fmt.Fprintln(os.Stderr, "Starting containers...") workdir, err := os.Getwd() @@ -258,7 +542,7 @@ func run(ctx context.Context, fsys afero.Fs, excludedContainers []string, dbConf } // Start Logflare - if utils.Config.Analytics.Enabled && !isContainerExcluded(utils.Config.Analytics.Image, excluded) { + if isLogflareEnabled { env := []string{ "DB_DATABASE=_supabase", "DB_HOSTNAME=" + dbConfig.Host, @@ -341,7 +625,7 @@ EOF } // Start vector - if utils.Config.Analytics.Enabled && !isContainerExcluded(utils.Config.Analytics.VectorImage, excluded) { + if isVectorEnabled { var vectorConfigBuf bytes.Buffer if err := vectorConfigTemplate.Option("missingkey=error").Execute(&vectorConfigBuf, vectorConfig{ ApiKey: utils.Config.Analytics.ApiKey, @@ -427,130 +711,8 @@ EOF } } - // Start Kong. - if !isContainerExcluded(utils.Config.Api.KongImage, excluded) { - var kongConfigBuf bytes.Buffer - if err := kongConfigTemplate.Option("missingkey=error").Execute(&kongConfigBuf, kongConfig{ - GotrueId: utils.GotrueId, - RestId: utils.RestId, - RealtimeId: utils.Config.Realtime.TenantId, - StorageId: utils.StorageId, - StudioId: utils.StudioId, - PgmetaId: utils.PgmetaId, - EdgeRuntimeId: utils.EdgeRuntimeId, - LogflareId: utils.LogflareId, - PoolerId: utils.PoolerId, - ApiHost: utils.Config.Hostname, - ApiPort: utils.Config.Api.Port, - BearerToken: fmt.Sprintf( - // If Authorization header is set to a self-minted JWT, we want to pass it down. - // Legacy supabase-js may set Authorization header to Bearer . We must remove it - // to avoid failing JWT validation. - // If Authorization header is missing, we want to match against apikey header to set the - // default JWT for downstream services. - // Finally, the apikey header may be set to a legacy JWT. In that case, we want to copy - // it to Authorization header for backwards compatibility. - `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '%s' and 'Bearer %s') or (headers.apikey == '%s' and 'Bearer %s') or headers.apikey)`, - utils.Config.Auth.SecretKey.Value, - utils.Config.Auth.ServiceRoleKey.Value, - utils.Config.Auth.PublishableKey.Value, - utils.Config.Auth.AnonKey.Value, - ), - QueryToken: fmt.Sprintf( - `$((query_params.apikey == '%s' and '%s') or (query_params.apikey == '%s' and '%s') or query_params.apikey)`, - utils.Config.Auth.SecretKey.Value, - utils.Config.Auth.ServiceRoleKey.Value, - utils.Config.Auth.PublishableKey.Value, - utils.Config.Auth.AnonKey.Value, - ), - }); err != nil { - return errors.Errorf("failed to exec template: %w", err) - } - - binds := []string{} - for id, tmpl := range utils.Config.Auth.Email.Template { - if len(tmpl.ContentPath) == 0 { - continue - } - hostPath := tmpl.ContentPath - if !filepath.IsAbs(tmpl.ContentPath) { - var err error - hostPath, err = filepath.Abs(hostPath) - if err != nil { - return errors.Errorf("failed to resolve absolute path: %w", err) - } - } - dockerPath := path.Join(nginxEmailTemplateDir, id+filepath.Ext(hostPath)) - binds = append(binds, fmt.Sprintf("%s:%s:rw", hostPath, dockerPath)) - } - - dockerPort := uint16(8000) - if utils.Config.Api.Tls.Enabled { - dockerPort = 8443 - } - if _, err := utils.DockerStart( - ctx, - container.Config{ - Image: utils.Config.Api.KongImage, - Env: []string{ - "KONG_DATABASE=off", - "KONG_DECLARATIVE_CONFIG=/home/kong/kong.yml", - "KONG_DNS_ORDER=LAST,A,CNAME", // https://github.com/supabase/cli/issues/14 - "KONG_PLUGINS=request-transformer,cors", - fmt.Sprintf("KONG_PORT_MAPS=%d:8000", utils.Config.Api.Port), - // Need to increase the nginx buffers in kong to avoid it rejecting the rather - // sizeable response headers azure can generate - // Ref: https://github.com/Kong/kong/issues/3974#issuecomment-482105126 - "KONG_NGINX_PROXY_PROXY_BUFFER_SIZE=160k", - "KONG_NGINX_PROXY_PROXY_BUFFERS=64 160k", - "KONG_NGINX_WORKER_PROCESSES=1", - // Use modern TLS certificate - "KONG_SSL_CERT=/home/kong/localhost.crt", - "KONG_SSL_CERT_KEY=/home/kong/localhost.key", - }, - Entrypoint: []string{"sh", "-c", `cat <<'EOF' > /home/kong/kong.yml && \ -cat <<'EOF' > /home/kong/custom_nginx.template && \ -cat <<'EOF' > /home/kong/localhost.crt && \ -cat <<'EOF' > /home/kong/localhost.key && \ -./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template -` + kongConfigBuf.String() + ` -EOF -` + nginxConfigEmbed + ` -EOF -` + string(utils.Config.Api.Tls.CertContent) + ` -EOF -` + string(utils.Config.Api.Tls.KeyContent) + ` -EOF -`}, - ExposedPorts: nat.PortSet{ - "8000/tcp": {}, - "8443/tcp": {}, - nat.Port(fmt.Sprintf("%d/tcp", nginxTemplateServerPort)): {}, - }, - }, - container.HostConfig{ - Binds: binds, - PortBindings: nat.PortMap{nat.Port(fmt.Sprintf("%d/tcp", dockerPort)): []nat.PortBinding{{ - HostPort: strconv.FormatUint(uint64(utils.Config.Api.Port), 10), - }}}, - RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}, - }, - network.NetworkingConfig{ - EndpointsConfig: map[string]*network.EndpointSettings{ - utils.NetId: { - Aliases: utils.KongAliases, - }, - }, - }, - utils.KongId, - ); err != nil { - return err - } - started = append(started, utils.KongId) - } - // Start GoTrue. - if utils.Config.Auth.Enabled && !isContainerExcluded(utils.Config.Auth.Image, excluded) { + if isAuthEnabled { var testOTP bytes.Buffer if len(utils.Config.Auth.Sms.TestOTP) > 0 { formatMapForEnvConfig(utils.Config.Auth.Sms.TestOTP, &testOTP) @@ -852,7 +1014,7 @@ EOF } // Start Mailpit - if utils.Config.Inbucket.Enabled && !isContainerExcluded(utils.Config.Inbucket.Image, excluded) { + if isInbucketEnabled { inbucketPortBindings := nat.PortMap{"8025/tcp": []nat.PortBinding{{ HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.Port), 10), }}} @@ -902,7 +1064,7 @@ EOF } // Start Realtime. - if utils.Config.Realtime.Enabled && !isContainerExcluded(utils.Config.Realtime.Image, excluded) { + if isRealtimeEnabled { if _, err := utils.DockerStart( ctx, container.Config{ @@ -959,7 +1121,7 @@ EOF } // Start PostgREST. - if utils.Config.Api.Enabled && !isContainerExcluded(utils.Config.Api.Image, excluded) { + if isRestEnabled { if _, err := utils.DockerStart( ctx, container.Config{ @@ -1096,7 +1258,7 @@ EOF } // Start all functions. - if utils.Config.EdgeRuntime.Enabled && !isContainerExcluded(utils.Config.EdgeRuntime.Image, excluded) { + if isEdgeRuntimeEnabled { dbUrl := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) if err := serve.ServeFunctions(ctx, "", nil, "", dbUrl, serve.RuntimeOption{}, fsys); err != nil { return err @@ -1105,7 +1267,7 @@ EOF } // Start pg-meta. - if utils.Config.Studio.Enabled && !isContainerExcluded(utils.Config.Studio.PgmetaImage, excluded) { + if isPgmetaEnabled { if _, err := utils.DockerStart( ctx, container.Config{ @@ -1142,75 +1304,8 @@ EOF started = append(started, utils.PgmetaId) } - // Start Studio. - if utils.Config.Studio.Enabled && !isContainerExcluded(utils.Config.Studio.Image, excluded) { - binds, _, err := serve.PopulatePerFunctionConfigs(workdir, "", nil, fsys) - if err != nil { - return err - } - - // Mount snippets directory for Studio to access - hostSnippetsPath := filepath.Join(workdir, utils.SnippetsDir) - containerSnippetsPath := utils.ToDockerPath(hostSnippetsPath) - binds = append(binds, fmt.Sprintf("%s:%s:rw", hostSnippetsPath, containerSnippetsPath)) - binds = utils.RemoveDuplicates(binds) - if _, err := utils.DockerStart( - ctx, - container.Config{ - Image: utils.Config.Studio.Image, - Env: []string{ - "CURRENT_CLI_VERSION=" + utils.Version, - "STUDIO_PG_META_URL=http://" + utils.PgmetaId + ":8080", - "POSTGRES_PASSWORD=" + dbConfig.Password, - "SUPABASE_URL=http://" + utils.KongId + ":8000", - "SUPABASE_PUBLIC_URL=" + utils.Config.Studio.ApiUrl, - "AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - "SUPABASE_ANON_KEY=" + utils.Config.Auth.AnonKey.Value, - "SUPABASE_SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey.Value, - "LOGFLARE_PRIVATE_ACCESS_TOKEN=" + utils.Config.Analytics.ApiKey, - "OPENAI_API_KEY=" + utils.Config.Studio.OpenaiApiKey.Value, - "PGRST_DB_SCHEMAS=" + strings.Join(utils.Config.Api.Schemas, ","), - "PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","), - fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows), - fmt.Sprintf("LOGFLARE_URL=http://%v:4000", utils.LogflareId), - fmt.Sprintf("NEXT_PUBLIC_ENABLE_LOGS=%v", utils.Config.Analytics.Enabled), - fmt.Sprintf("NEXT_ANALYTICS_BACKEND_PROVIDER=%v", utils.Config.Analytics.Backend), - "EDGE_FUNCTIONS_MANAGEMENT_FOLDER=" + utils.ToDockerPath(filepath.Join(workdir, utils.FunctionsDir)), - "SNIPPETS_MANAGEMENT_FOLDER=" + containerSnippetsPath, - // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 - "HOSTNAME=0.0.0.0", - "POSTGRES_USER_READ_WRITE=postgres", - }, - Healthcheck: &container.HealthConfig{ - Test: []string{"CMD-SHELL", `node --eval="fetch('http://127.0.0.1:3000/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`}, - Interval: 10 * time.Second, - Timeout: 2 * time.Second, - Retries: 3, - }, - }, - container.HostConfig{ - Binds: binds, - PortBindings: nat.PortMap{"3000/tcp": []nat.PortBinding{{ - HostPort: strconv.FormatUint(uint64(utils.Config.Studio.Port), 10), - }}}, - RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}, - }, - network.NetworkingConfig{ - EndpointsConfig: map[string]*network.EndpointSettings{ - utils.NetId: { - Aliases: utils.StudioAliases, - }, - }, - }, - utils.StudioId, - ); err != nil { - return err - } - started = append(started, utils.StudioId) - } - // Start pooler. - if utils.Config.Db.Pooler.Enabled && !isContainerExcluded(utils.Config.Db.Pooler.Image, excluded) { + if isPoolerEnabled { portSession := uint16(5432) portTransaction := uint16(6543) dockerPort := portTransaction @@ -1286,6 +1381,79 @@ EOF started = append(started, utils.PoolerId) } + // Start Kong after its Apple-runtime upstreams exist. + if isKongEnabled { + if err := startKong(ctx, KongDependencies{ + Gotrue: isAuthEnabled, + Rest: isRestEnabled, + Realtime: isRealtimeEnabled, + Storage: isStorageEnabled, + Studio: false, + Pgmeta: isPgmetaEnabled, + Edge: isEdgeRuntimeEnabled, + Logflare: isLogflareEnabled, + Pooler: isPoolerEnabled, + }); err != nil { + return err + } + started = append(started, utils.KongId) + } + + // Start Studio. + if isStudioEnabled { + binds, _, err := serve.PopulatePerFunctionConfigs(workdir, "", nil, fsys) + if err != nil { + return err + } + + snippetsManagementFolder := "" + // Mount snippets directory for Studio to access when present. + hostSnippetsPath := filepath.Join(workdir, utils.SnippetsDir) + if info, err := os.Stat(hostSnippetsPath); err == nil && info.IsDir() { + containerSnippetsPath := utils.ToDockerPath(hostSnippetsPath) + binds = append(binds, fmt.Sprintf("%s:%s:rw", hostSnippetsPath, containerSnippetsPath)) + snippetsManagementFolder = containerSnippetsPath + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Errorf("failed to inspect snippets directory: %w", err) + } + binds = utils.RemoveDuplicates(binds) + env, err := buildStudioEnv(ctx, workdir, dbConfig, snippetsManagementFolder, isKongEnabled, isPgmetaEnabled, isLogflareEnabled) + if err != nil { + return err + } + if _, err := utils.DockerStart( + ctx, + container.Config{ + Image: utils.Config.Studio.Image, + Env: env, + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD-SHELL", `node --eval="fetch('http://127.0.0.1:3000/api/platform/profile').then((r) => {if (!r.ok) throw new Error(r.status)})"`}, + Interval: 10 * time.Second, + Timeout: 2 * time.Second, + Retries: 3, + }, + }, + container.HostConfig{ + Binds: binds, + PortBindings: nat.PortMap{"3000/tcp": []nat.PortBinding{{ + HostPort: strconv.FormatUint(uint64(utils.Config.Studio.Port), 10), + }}}, + RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped}, + }, + network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + utils.NetId: { + Aliases: utils.StudioAliases, + }, + }, + }, + utils.StudioId, + ); err != nil { + return err + } + started = append(started, utils.StudioId) + } + fmt.Fprintln(os.Stderr, "Waiting for health checks...") if utils.NoBackupVolume && slices.Contains(started, utils.StorageId) { if err := start.WaitForHealthyService(ctx, serviceTimeout, utils.StorageId); err != nil { diff --git a/internal/start/start_test.go b/internal/start/start_test.go index bec6da5cf0..87c45b6329 100644 --- a/internal/start/start_test.go +++ b/internal/start/start_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "regexp" + "strings" "testing" "github.com/docker/docker/api/types" @@ -81,8 +82,17 @@ func TestStartCommand(t *testing.T) { }}) gock.New(utils.Docker.DaemonHost()). Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). + Persist(). Reply(http.StatusOK). JSON(running) + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/networks"). + Reply(http.StatusOK). + JSON([]network.Summary{}) + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/volumes"). + Reply(http.StatusOK). + JSON(volume.ListResponse{}) // Run test err := Run(context.Background(), fsys, []string{}, false) // Check error @@ -255,6 +265,238 @@ func TestDatabaseStart(t *testing.T) { }) } +func TestRuntimeContainerHost(t *testing.T) { + t.Run("uses container ip on apple for started services", func(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalResolver := resolveContainerIP + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + resolveContainerIP = originalResolver + }) + utils.Config.Local.Runtime = config.AppleContainerRuntime + resolveContainerIP = func(_ context.Context, containerId, networkName string) (string, error) { + assert.Equal(t, utils.NetId, networkName) + return "192.168.0.10", nil + } + + host, err := runtimeContainerHost(context.Background(), "test-service", true) + require.NoError(t, err) + assert.Equal(t, "192.168.0.10", host) + }) + + t.Run("keeps container name when runtime does not need resolution", func(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalResolver := resolveContainerIP + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + resolveContainerIP = originalResolver + }) + utils.Config.Local.Runtime = config.DockerRuntime + resolveContainerIP = func(_ context.Context, _, _ string) (string, error) { + t.Fatal("resolver should not be called") + return "", nil + } + + host, err := runtimeContainerHost(context.Background(), "test-service", true) + require.NoError(t, err) + assert.Equal(t, "test-service", host) + }) +} + +func TestBuildKongConfig(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalResolver := resolveContainerIP + originalIDs := struct { + gotrue, rest, realtime, storage, studio, pgmeta, edge, logflare, pooler string + }{ + gotrue: utils.GotrueId, + rest: utils.RestId, + realtime: utils.RealtimeId, + storage: utils.StorageId, + studio: utils.StudioId, + pgmeta: utils.PgmetaId, + edge: utils.EdgeRuntimeId, + logflare: utils.LogflareId, + pooler: utils.PoolerId, + } + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + resolveContainerIP = originalResolver + utils.GotrueId = originalIDs.gotrue + utils.RestId = originalIDs.rest + utils.RealtimeId = originalIDs.realtime + utils.StorageId = originalIDs.storage + utils.StudioId = originalIDs.studio + utils.PgmetaId = originalIDs.pgmeta + utils.EdgeRuntimeId = originalIDs.edge + utils.LogflareId = originalIDs.logflare + utils.PoolerId = originalIDs.pooler + }) + utils.Config.Local.Runtime = config.AppleContainerRuntime + utils.GotrueId = "test-gotrue" + utils.RestId = "test-rest" + utils.RealtimeId = "test-realtime" + utils.StorageId = "test-storage" + utils.StudioId = "test-studio" + utils.PgmetaId = "test-pgmeta" + utils.EdgeRuntimeId = "test-edge" + utils.LogflareId = "test-logflare" + utils.PoolerId = "test-pooler" + resolveContainerIP = func(_ context.Context, containerId, _ string) (string, error) { + return map[string]string{ + "test-gotrue": "192.168.0.11", + "test-rest": "192.168.0.12", + "test-realtime": "192.168.0.13", + "test-storage": "192.168.0.14", + "test-pgmeta": "192.168.0.15", + "test-edge": "192.168.0.16", + "test-logflare": "192.168.0.17", + "test-pooler": "192.168.0.18", + }[containerId], nil + } + + cfg, err := buildKongConfig(context.Background(), KongDependencies{ + Gotrue: true, + Rest: true, + Realtime: true, + Storage: true, + Studio: false, + Pgmeta: true, + Edge: true, + Logflare: true, + Pooler: true, + }) + require.NoError(t, err) + assert.Equal(t, "192.168.0.11", cfg.GotrueId) + assert.Equal(t, "192.168.0.12", cfg.RestId) + assert.Equal(t, "192.168.0.13", cfg.RealtimeId) + assert.Equal(t, "192.168.0.14", cfg.StorageId) + assert.Equal(t, "test-studio", cfg.StudioId) + assert.Equal(t, "192.168.0.15", cfg.PgmetaId) + assert.Equal(t, "192.168.0.16", cfg.EdgeRuntimeId) + assert.Equal(t, "192.168.0.17", cfg.LogflareId) + assert.Equal(t, "192.168.0.18", cfg.PoolerId) +} + +func TestRuntimeContainerURL(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalResolver := resolveContainerIP + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + resolveContainerIP = originalResolver + }) + utils.Config.Local.Runtime = config.AppleContainerRuntime + resolveContainerIP = func(_ context.Context, _, _ string) (string, error) { + return "192.168.0.20", nil + } + + url, err := runtimeContainerURL(context.Background(), "test-kong", 8000, true) + require.NoError(t, err) + assert.Equal(t, "http://192.168.0.20:8000", url) +} + +func TestReconcileStaleProjectContainers(t *testing.T) { + originalLister := listProjectContainers + originalRemover := removeProjectContainer + t.Cleanup(func() { + listProjectContainers = originalLister + removeProjectContainer = originalRemover + }) + + t.Run("removes only stopped project containers", func(t *testing.T) { + var removed []string + listProjectContainers = func(_ context.Context, projectId string, all bool) ([]utils.ContainerInfo, error) { + assert.Equal(t, "demo", projectId) + assert.True(t, all) + return []utils.ContainerInfo{ + {ID: "supabase-db-demo", Running: true}, + {ID: "supabase-rest-demo", Running: false}, + }, nil + } + removeProjectContainer = func(_ context.Context, containerId string, removeVolumes, force bool) error { + assert.True(t, removeVolumes) + assert.True(t, force) + removed = append(removed, containerId) + return nil + } + + err := reconcileStaleProjectContainers(context.Background(), "demo") + + require.NoError(t, err) + assert.Equal(t, []string{"supabase-rest-demo"}, removed) + }) + + t.Run("returns removal errors", func(t *testing.T) { + listProjectContainers = func(_ context.Context, _ string, _ bool) ([]utils.ContainerInfo, error) { + return []utils.ContainerInfo{{ID: "supabase-rest-demo", Running: false}}, nil + } + removeProjectContainer = func(_ context.Context, _ string, _, _ bool) error { + return errors.New("boom") + } + + err := reconcileStaleProjectContainers(context.Background(), "demo") + + require.Error(t, err) + assert.ErrorContains(t, err, "failed to remove stale container") + }) +} + +func TestBuildStudioEnv(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalResolver := resolveContainerIP + originalIDs := struct { + kong, pgmeta, logflare string + }{ + kong: utils.KongId, + pgmeta: utils.PgmetaId, + logflare: utils.LogflareId, + } + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + resolveContainerIP = originalResolver + utils.KongId = originalIDs.kong + utils.PgmetaId = originalIDs.pgmeta + utils.LogflareId = originalIDs.logflare + }) + utils.Config.Local.Runtime = config.AppleContainerRuntime + utils.KongId = "test-kong" + utils.PgmetaId = "test-pgmeta" + utils.LogflareId = "test-logflare" + resolveContainerIP = func(_ context.Context, containerId, _ string) (string, error) { + return map[string]string{ + "test-kong": "192.168.0.30", + "test-pgmeta": "192.168.0.31", + }[containerId], nil + } + + env, err := buildStudioEnv( + context.Background(), + "/tmp/demo", + pgconn.Config{Host: "192.168.0.2", Port: 5432, Database: "postgres", Password: "postgres"}, + "", + true, + true, + false, + ) + require.NoError(t, err) + + assert.Contains(t, env, "POSTGRES_HOST=192.168.0.2") + assert.Contains(t, env, "POSTGRES_PORT=5432") + assert.Contains(t, env, "POSTGRES_DB=postgres") + assert.Contains(t, env, "STUDIO_PG_META_URL=http://192.168.0.31:8080") + assert.Contains(t, env, "SUPABASE_URL=http://192.168.0.30:8000") + assert.Contains(t, env, "SNIPPETS_MANAGEMENT_FOLDER=") + + foundFunctionsDir := false + for _, item := range env { + if strings.HasPrefix(item, "EDGE_FUNCTIONS_MANAGEMENT_FOLDER=") { + foundFunctionsDir = true + assert.Contains(t, item, "/tmp/demo/") + } + } + assert.True(t, foundFunctionsDir) +} + func TestFormatMapForEnvConfig(t *testing.T) { t.Run("It produces the correct format and removes the trailing comma", func(t *testing.T) { testcases := []struct { diff --git a/internal/start/templates/kong.yml b/internal/start/templates/kong.yml index 0c185eb6e4..e9848008fc 100644 --- a/internal/start/templates/kong.yml +++ b/internal/start/templates/kong.yml @@ -110,6 +110,9 @@ services: - name: cors - name: request-transformer config: + add: + headers: + - "Host: {{ .RealtimeTenantId }}" replace: querystring: - "apikey:{{ .QueryToken }}" @@ -128,9 +131,11 @@ services: config: add: headers: + - "Host: {{ .RealtimeTenantId }}" - "Authorization: {{ .BearerToken }}" replace: headers: + - "Host: {{ .RealtimeTenantId }}" - "Authorization: {{ .BearerToken }}" # S3-compatible storage endpoint (no Authorization header transformation) - name: storage-v1-s3 diff --git a/internal/status/status.go b/internal/status/status.go index fcb628917d..80481f64e3 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -11,12 +11,12 @@ import ( "net/url" "os" "slices" + "sort" + "strings" "sync" "time" "github.com/Netflix/go-env" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" "github.com/go-errors/errors" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/tw" @@ -47,6 +47,12 @@ type CustomName struct { StorageS3Region string `env:"storage.s3_region,default=S3_PROTOCOL_REGION"` } +var ( + listProjectContainers = utils.ListProjectContainers + listProjectNetworks = utils.ListProjectNetworks + listProjectVolumes = utils.ListProjectVolumes +) + func (c *CustomName) toValues(exclude ...string) map[string]string { values := map[string]string{ c.DbURL: fmt.Sprintf("postgresql://%s@%s:%d/postgres", url.UserPassword("postgres", utils.Config.Db.Password), utils.Config.Hostname, utils.Config.Db.Port), @@ -110,28 +116,24 @@ func Run(ctx context.Context, names CustomName, format string, fsys afero.Fs) er } if format == utils.OutputPretty { fmt.Fprintf(os.Stderr, "%s local development setup is running.\n\n", utils.Aqua("supabase")) - PrettyPrint(os.Stdout, stopped...) + PrettyPrint(ctx, os.Stdout, stopped...) return nil } return printStatus(names, format, os.Stdout, stopped...) } func checkServiceHealth(ctx context.Context) ([]string, error) { - resp, err := utils.Docker.ContainerList(ctx, container.ListOptions{ - Filters: utils.CliProjectFilter(utils.Config.ProjectId), - }) + resp, err := utils.ListProjectContainers(ctx, utils.Config.ProjectId, false) if err != nil { return nil, errors.Errorf("failed to list running containers: %w", err) } running := make(map[string]struct{}, len(resp)) - for _, c := range resp { - for _, n := range c.Names { - running[n] = struct{}{} - } + for _, item := range resp { + running[item.ID] = struct{}{} } var stopped []string for _, containerId := range utils.GetDockerIds() { - if _, ok := running["/"+containerId]; !ok { + if _, ok := running[containerId]; !ok { stopped = append(stopped, containerId) } } @@ -139,14 +141,7 @@ func checkServiceHealth(ctx context.Context) ([]string, error) { } func assertContainerHealthy(ctx context.Context, container string) error { - if resp, err := utils.Docker.ContainerInspect(ctx, container); err != nil { - return errors.Errorf("failed to inspect container health: %w", err) - } else if !resp.State.Running { - return errors.Errorf("%s container is not running: %s", container, resp.State.Status) - } else if resp.State.Health != nil && resp.State.Health.Status != types.Healthy { - return errors.Errorf("%s container is not ready: %s", container, resp.State.Health.Status) - } - return nil + return utils.AssertServiceHealthy(ctx, container) } func IsServiceReady(ctx context.Context, container string) error { @@ -217,7 +212,7 @@ func printStatus(names CustomName, format string, w io.Writer, exclude ...string return utils.EncodeOutput(format, w, values) } -func PrettyPrint(w io.Writer, exclude ...string) { +func PrettyPrint(ctx context.Context, w io.Writer, exclude ...string) { logger := utils.GetDebugLogger() names := CustomName{} @@ -225,8 +220,16 @@ func PrettyPrint(w io.Writer, exclude ...string) { fmt.Fprintln(logger, err) } values := names.toValues(exclude...) + runtimeItems, err := buildRuntimeItems(ctx, exclude...) + if err != nil { + fmt.Fprintln(logger, err) + } groups := []OutputGroup{ + { + Name: "🧭 Runtime", + Items: runtimeItems, + }, { Name: "🔧 Development Tools", Items: []OutputItem{ @@ -277,6 +280,64 @@ func PrettyPrint(w io.Writer, exclude ...string) { } } +func buildRuntimeItems(ctx context.Context, exclude ...string) ([]OutputItem, error) { + containers, err := listProjectContainers(ctx, utils.Config.ProjectId, true) + if err != nil { + return nil, err + } + networks, err := listProjectNetworks(ctx, utils.Config.ProjectId) + if err != nil { + return nil, err + } + volumes, err := listProjectVolumes(ctx, utils.Config.ProjectId) + if err != nil { + return nil, err + } + containerNames := make([]string, 0, len(containers)) + for _, item := range containers { + containerNames = append(containerNames, item.ID) + } + networkNames := make([]string, 0, len(networks)) + for _, item := range networks { + networkNames = append(networkNames, item.Name) + } + volumeNames := make([]string, 0, len(volumes)) + for _, item := range volumes { + volumeNames = append(volumeNames, item.Name) + } + sort.Strings(containerNames) + sort.Strings(networkNames) + sort.Strings(volumeNames) + items := []OutputItem{ + {Label: "Runtime", Value: string(utils.Config.Local.Runtime), Type: Text}, + {Label: "Project", Value: utils.Config.ProjectId, Type: Text}, + {Label: "Ports", Value: strings.Join(runtimePorts(exclude...), ", "), Type: Text}, + {Label: "Containers", Value: strings.Join(containerNames, ", "), Type: Text}, + {Label: "Networks", Value: strings.Join(networkNames, ", "), Type: Text}, + {Label: "Volumes", Value: strings.Join(volumeNames, ", "), Type: Text}, + } + return items, nil +} + +func runtimePorts(exclude ...string) []string { + var ports []string + ports = append(ports, fmt.Sprintf("db:%d", utils.Config.Db.Port)) + if utils.Config.Api.Enabled && !slices.Contains(exclude, utils.RestId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Api.Image)) { + ports = append(ports, fmt.Sprintf("api:%d", utils.Config.Api.Port)) + } + if utils.Config.Studio.Enabled && !slices.Contains(exclude, utils.StudioId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Studio.Image)) { + ports = append(ports, fmt.Sprintf("studio:%d", utils.Config.Studio.Port)) + } + if utils.Config.Inbucket.Enabled && !slices.Contains(exclude, utils.InbucketId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Inbucket.Image)) { + ports = append(ports, fmt.Sprintf("mailpit:%d", utils.Config.Inbucket.Port)) + } + if utils.Config.Db.Pooler.Enabled && !slices.Contains(exclude, utils.PoolerId) && !slices.Contains(exclude, utils.ShortContainerImageName(utils.Config.Db.Pooler.Image)) { + ports = append(ports, fmt.Sprintf("pooler:%d", utils.Config.Db.Pooler.Port)) + } + sort.Strings(ports) + return ports +} + type OutputType string const ( diff --git a/internal/status/status_test.go b/internal/status/status_test.go index c7bfc1bc4f..349043a2f5 100644 --- a/internal/status/status_test.go +++ b/internal/status/status_test.go @@ -5,15 +5,19 @@ import ( "context" "errors" "net/http" + "strings" "testing" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/volume" "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" + "github.com/supabase/cli/pkg/config" ) func TestStatusCommand(t *testing.T) { @@ -40,8 +44,17 @@ func TestStatusCommand(t *testing.T) { }}) gock.New(utils.Docker.DaemonHost()). Get("/v" + utils.Docker.ClientVersion() + "/containers/json"). + Persist(). Reply(http.StatusOK). JSON(running) + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/networks"). + Reply(http.StatusOK). + JSON([]network.Summary{}) + gock.New(utils.Docker.DaemonHost()). + Get("/v" + utils.Docker.ClientVersion() + "/volumes"). + Reply(http.StatusOK). + JSON(volume.ListResponse{}) // Run test assert.NoError(t, Run(context.Background(), CustomName{}, utils.OutputPretty, fsys)) // Check error @@ -176,3 +189,83 @@ func TestPrintStatus(t *testing.T) { assert.Equal(t, "DB_URL = \"postgresql://postgres:postgres@127.0.0.1:0/postgres\"\n", stdout.String()) }) } + +func TestBuildRuntimeItems(t *testing.T) { + originalContainers := listProjectContainers + originalNetworks := listProjectNetworks + originalVolumes := listProjectVolumes + t.Cleanup(func() { + listProjectContainers = originalContainers + listProjectNetworks = originalNetworks + listProjectVolumes = originalVolumes + }) + utils.Config.Local.Runtime = config.AppleContainerRuntime + utils.Config.ProjectId = "demo" + utils.Config.Db.Port = 54322 + utils.Config.Api.Enabled = true + utils.Config.Api.Port = 54321 + utils.Config.Studio.Enabled = true + utils.Config.Studio.Port = 54323 + utils.Config.Inbucket.Enabled = true + utils.Config.Inbucket.Port = 54324 + listProjectContainers = func(_ context.Context, projectId string, all bool) ([]utils.ContainerInfo, error) { + assert.Equal(t, "demo", projectId) + assert.True(t, all) + return []utils.ContainerInfo{{ID: "supabase-db-demo"}, {ID: "supabase-kong-demo"}}, nil + } + listProjectNetworks = func(_ context.Context, projectId string) ([]utils.NetworkInfo, error) { + assert.Equal(t, "demo", projectId) + return []utils.NetworkInfo{{Name: "supabase-network-demo"}}, nil + } + listProjectVolumes = func(_ context.Context, projectId string) ([]utils.VolumeInfo, error) { + assert.Equal(t, "demo", projectId) + return []utils.VolumeInfo{{Name: "supabase-db-demo"}}, nil + } + + items, err := buildRuntimeItems(context.Background()) + + require.NoError(t, err) + assert.Contains(t, items, OutputItem{Label: "Runtime", Value: "apple-container", Type: Text}) + assert.Contains(t, items, OutputItem{Label: "Project", Value: "demo", Type: Text}) + assert.Contains(t, items, OutputItem{Label: "Networks", Value: "supabase-network-demo", Type: Text}) + assert.Contains(t, items, OutputItem{Label: "Volumes", Value: "supabase-db-demo", Type: Text}) +} + +func TestPrettyPrintIncludesRuntimeResources(t *testing.T) { + originalContainers := listProjectContainers + originalNetworks := listProjectNetworks + originalVolumes := listProjectVolumes + t.Cleanup(func() { + listProjectContainers = originalContainers + listProjectNetworks = originalNetworks + listProjectVolumes = originalVolumes + }) + utils.Config.Local.Runtime = config.AppleContainerRuntime + utils.Config.ProjectId = "demo" + utils.Config.Db.Port = 54322 + utils.Config.Api.Enabled = false + utils.Config.Auth.Enabled = false + utils.Config.Storage.Enabled = false + utils.Config.Realtime.Enabled = false + utils.Config.Studio.Enabled = false + utils.Config.Analytics.Enabled = false + utils.Config.Inbucket.Enabled = false + listProjectContainers = func(_ context.Context, _ string, _ bool) ([]utils.ContainerInfo, error) { + return []utils.ContainerInfo{{ID: "supabase-db-demo"}}, nil + } + listProjectNetworks = func(_ context.Context, _ string) ([]utils.NetworkInfo, error) { + return []utils.NetworkInfo{{Name: "supabase-network-demo"}}, nil + } + listProjectVolumes = func(_ context.Context, _ string) ([]utils.VolumeInfo, error) { + return []utils.VolumeInfo{{Name: "supabase-db-demo"}}, nil + } + + var stdout bytes.Buffer + PrettyPrint(context.Background(), &stdout) + + out := stdout.String() + assert.True(t, strings.Contains(out, "Runtime")) + assert.True(t, strings.Contains(out, "apple-container")) + assert.True(t, strings.Contains(out, "supabase-network-demo")) + assert.True(t, strings.Contains(out, "supabase-db-demo")) +} diff --git a/internal/stop/stop.go b/internal/stop/stop.go index da98f16002..ec9e96070e 100644 --- a/internal/stop/stop.go +++ b/internal/stop/stop.go @@ -6,7 +6,6 @@ import ( "fmt" "io" - "github.com/docker/docker/api/types/volume" "github.com/spf13/afero" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" @@ -33,15 +32,23 @@ func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afer } fmt.Println("Stopped " + utils.Aqua("supabase") + " local development setup.") - if resp, err := utils.Docker.VolumeList(ctx, volume.ListOptions{ - Filters: utils.CliProjectFilter(searchProjectIdFilter), - }); err == nil && len(resp.Volumes) > 0 { + if volumes, err := utils.ListProjectVolumes(ctx, searchProjectIdFilter); err == nil && len(volumes) > 0 { if len(searchProjectIdFilter) > 0 { - listVolume := fmt.Sprintf("docker volume ls --filter label=%s=%s", utils.CliProjectLabel, searchProjectIdFilter) - utils.CmdSuggestion = "Local data are backed up to docker volume. Use docker to show them: " + utils.Aqua(listVolume) + if utils.UsesAppleContainerRuntime() { + listVolume := fmt.Sprintf("container volume list --format json | jq '.[] | select(.labels.\"%s\" == \"%s\")'", utils.CliProjectLabel, searchProjectIdFilter) + utils.CmdSuggestion = "Local data are backed up to apple container volumes. Use the container CLI to show them: " + utils.Aqua(listVolume) + } else { + listVolume := fmt.Sprintf("docker volume ls --filter label=%s=%s", utils.CliProjectLabel, searchProjectIdFilter) + utils.CmdSuggestion = "Local data are backed up to docker volume. Use docker to show them: " + utils.Aqua(listVolume) + } } else { - listVolume := fmt.Sprintf("docker volume ls --filter label=%s", utils.CliProjectLabel) - utils.CmdSuggestion = "Local data are backed up to docker volume. Use docker to show them: " + utils.Aqua(listVolume) + if utils.UsesAppleContainerRuntime() { + listVolume := fmt.Sprintf("container volume list --format json | jq '.[] | select(.labels.\"%s\")'", utils.CliProjectLabel) + utils.CmdSuggestion = "Local data are backed up to apple container volumes. Use the container CLI to show them: " + utils.Aqua(listVolume) + } else { + listVolume := fmt.Sprintf("docker volume ls --filter label=%s", utils.CliProjectLabel) + utils.CmdSuggestion = "Local data are backed up to docker volume. Use docker to show them: " + utils.Aqua(listVolume) + } } } return nil diff --git a/internal/utils/apple_container.go b/internal/utils/apple_container.go new file mode 100644 index 0000000000..ee7a41053b --- /dev/null +++ b/internal/utils/apple_container.go @@ -0,0 +1,681 @@ +package utils + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/containerd/errdefs" + "github.com/docker/cli/cli/compose/loader" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" + "github.com/go-errors/errors" +) + +const suggestAppleContainerInstall = "Apple's container CLI is a prerequisite for the apple-container runtime. Install it and run `container system start` first." + +var execContainerCommand = exec.CommandContext + +const ( + appleResourceReadyInterval = 100 * time.Millisecond + appleResourceReadyTimeout = 5 * time.Second +) + +type appleContainerConfig struct { + ID string `json:"id"` + Labels map[string]string `json:"labels"` + Mounts []appleMountRecord `json:"mounts"` +} + +type appleMountRecord struct { + Source string `json:"source"` + Target string `json:"target"` + Destination string `json:"destination"` + Type json.RawMessage `json:"type"` + ReadOnly bool `json:"readOnly"` + Options []string `json:"options"` +} + +type appleContainerRecord struct { + Configuration appleContainerConfig `json:"configuration"` + Status string `json:"status"` + Networks []struct { + Network string `json:"network"` + IPv4Address string `json:"ipv4Address"` + } `json:"networks"` +} + +type appleVolumeRecord struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` +} + +type appleNetworkRecord struct { + ID string `json:"id"` + Config struct { + Labels map[string]string `json:"labels"` + } `json:"config"` +} + +func appleStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) { + args, err := buildAppleContainerArgs(ctx, config, hostConfig, networkingConfig, containerName, true, false) + if err != nil { + return "", err + } + output, err := runContainerCommandOutput(ctx, args...) + if err != nil { + return "", err + } + return strings.TrimSpace(output), nil +} + +func appleRunOnceWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { + args, err := buildAppleContainerArgs(ctx, config, hostConfig, networkingConfig, containerName, false, true) + if err != nil { + return err + } + return runContainerCommand(ctx, stdout, stderr, args...) +} + +func appleExecOnceWithStream(ctx context.Context, containerId, workdir string, env, cmd []string, stdout, stderr io.Writer) error { + args := []string{"exec"} + for _, item := range env { + args = append(args, "--env", item) + } + if len(workdir) > 0 { + args = append(args, "--workdir", workdir) + } + args = append(args, containerId) + args = append(args, cmd...) + return runContainerCommand(ctx, stdout, stderr, args...) +} + +func appleStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer) error { + return runContainerCommand(ctx, stdout, stderr, "logs", "--follow", containerId) +} + +func appleStreamLogsOnce(ctx context.Context, containerId string, stdout, stderr io.Writer) error { + return runContainerCommand(ctx, stdout, stderr, "logs", containerId) +} + +func appleRemoveContainer(ctx context.Context, containerId string, force bool) error { + args := []string{"delete"} + if force { + args = append(args, "--force") + } + args = append(args, containerId) + if _, err := runContainerCommandOutput(ctx, args...); err != nil { + if isAppleNotFound(err) { + return errdefs.ErrNotFound + } + return err + } + return nil +} + +func appleRemoveVolume(ctx context.Context, volumeName string, force bool) error { + return appleRemoveVolumeWithRun(ctx, volumeName, force, runContainerCommandOutput) +} + +func appleRemoveVolumeWithRun(ctx context.Context, volumeName string, force bool, run func(context.Context, ...string) (string, error)) error { + args := []string{"volume", "delete"} + _ = force + args = append(args, volumeName) + if _, err := run(ctx, args...); err != nil { + if isAppleNotFound(err) { + return errdefs.ErrNotFound + } + return err + } + return nil +} + +func appleRestartContainer(ctx context.Context, containerId string) error { + return appleRestartContainerWithRun(ctx, containerId, runContainerCommandOutput) +} + +func appleRestartContainerWithRun(ctx context.Context, containerId string, run func(context.Context, ...string) (string, error)) error { + if _, err := run(ctx, "stop", containerId); err != nil { + if isAppleNotFound(err) { + return errdefs.ErrNotFound + } + return err + } + if _, err := run(ctx, "start", containerId); err != nil { + if isAppleNotFound(err) { + return errdefs.ErrNotFound + } + return err + } + return nil +} + +func appleInspectContainer(ctx context.Context, containerId string) (ContainerInfo, error) { + output, err := runContainerCommandOutput(ctx, "inspect", containerId) + if err != nil { + if isAppleNotFound(err) { + return ContainerInfo{}, errdefs.ErrNotFound + } + return ContainerInfo{}, err + } + var records []appleContainerRecord + if err := json.Unmarshal([]byte(output), &records); err != nil { + return ContainerInfo{}, errors.Errorf("failed to decode container inspect: %w", err) + } + if len(records) == 0 { + return ContainerInfo{}, errdefs.ErrNotFound + } + item := records[0] + info := ContainerInfo{ + ID: item.Configuration.ID, + Names: []string{item.Configuration.ID}, + Labels: item.Configuration.Labels, + Status: item.Status, + Running: item.Status == "running", + Mounts: make([]ContainerMount, 0, len(item.Configuration.Mounts)), + NetworkIPs: map[string]string{}, + } + for _, network := range item.Networks { + if len(network.Network) > 0 && len(network.IPv4Address) > 0 { + info.NetworkIPs[network.Network] = strings.TrimSuffix(network.IPv4Address, "/24") + } + } + for _, m := range item.Configuration.Mounts { + info.Mounts = append(info.Mounts, ContainerMount{ + Source: m.Source, + Target: m.mountTarget(), + Type: m.mountType(), + ReadOnly: m.isReadOnly(), + }) + } + return info, nil +} + +func appleListContainers(ctx context.Context, all bool) ([]ContainerInfo, error) { + args := []string{"list", "--format", "json"} + if all { + args = append(args, "--all") + } + output, err := runContainerCommandOutput(ctx, args...) + if err != nil { + return nil, err + } + var records []appleContainerRecord + if err := json.Unmarshal([]byte(output), &records); err != nil { + return nil, errors.Errorf("failed to decode container list: %w", err) + } + result := make([]ContainerInfo, 0, len(records)) + for _, item := range records { + info := ContainerInfo{ + ID: item.Configuration.ID, + Names: []string{item.Configuration.ID}, + Labels: item.Configuration.Labels, + Status: item.Status, + Running: item.Status == "running", + NetworkIPs: map[string]string{}, + } + for _, network := range item.Networks { + if len(network.Network) > 0 && len(network.IPv4Address) > 0 { + info.NetworkIPs[network.Network] = strings.TrimSuffix(network.IPv4Address, "/24") + } + } + result = append(result, info) + } + return result, nil +} + +func appleListVolumes(ctx context.Context) ([]VolumeInfo, error) { + output, err := runContainerCommandOutput(ctx, "volume", "list", "--format", "json") + if err != nil { + return nil, err + } + var records []appleVolumeRecord + if err := json.Unmarshal([]byte(output), &records); err != nil { + return nil, errors.Errorf("failed to decode volume list: %w", err) + } + result := make([]VolumeInfo, 0, len(records)) + for _, item := range records { + result = append(result, VolumeInfo{Name: item.Name, Labels: item.Labels}) + } + return result, nil +} + +func appleVolumeExists(ctx context.Context, name string) (bool, error) { + _, err := runContainerCommandOutput(ctx, "volume", "inspect", name) + if err == nil { + return true, nil + } + if isAppleNotFound(err) { + return false, nil + } + return false, err +} + +func appleRunHealthcheck(ctx context.Context, containerId string, test []string) error { + if len(test) == 0 { + return nil + } + switch test[0] { + case "NONE": + return nil + case "CMD": + return appleExecOnceWithStream(ctx, containerId, "", nil, test[1:], io.Discard, io.Discard) + case "CMD-SHELL": + command := "" + if len(test) > 1 { + command = test[1] + } + return appleExecOnceWithStream(ctx, containerId, "", nil, []string{"sh", "-c", command}, io.Discard, io.Discard) + default: + return appleExecOnceWithStream(ctx, containerId, "", nil, test, io.Discard, io.Discard) + } +} + +func appleRemoveAll(ctx context.Context, w io.Writer, projectId string) error { + fmt.Fprintln(w, "Stopping containers...") + containers, err := appleListContainers(ctx, true) + if err != nil { + return errors.Errorf("failed to list containers: %w", err) + } + containers = filterProjectContainers(containers, projectId) + var running []string + var all []string + for _, item := range containers { + all = append(all, item.ID) + if item.Running { + running = append(running, item.ID) + } + } + if err := appleStopAndDeleteContainers(ctx, running, all, runContainerCommandOutput); err != nil { + return err + } + if NoBackupVolume { + volumes, err := appleListVolumes(ctx) + if err != nil { + return errors.Errorf("failed to list volumes: %w", err) + } + volumes = filterProjectVolumes(volumes, projectId) + if len(volumes) > 0 { + args := []string{"volume", "delete"} + for _, item := range volumes { + args = append(args, item.Name) + } + if _, err := runContainerCommandOutput(ctx, args...); err != nil { + return errors.Errorf("failed to delete volumes: %w", err) + } + } + } + networks, err := appleListNetworks(ctx) + if err != nil { + return errors.Errorf("failed to list networks: %w", err) + } + var networkNames []string + for _, item := range networks { + if matchesProjectLabel(item.Config.Labels, projectId) { + networkNames = append(networkNames, item.ID) + } + } + if len(networkNames) > 0 { + args := append([]string{"network", "delete"}, networkNames...) + if _, err := runContainerCommandOutput(ctx, args...); err != nil { + return errors.Errorf("failed to delete networks: %w", err) + } + } + return nil +} + +func appleStopAndDeleteContainers(ctx context.Context, running, all []string, run func(context.Context, ...string) (string, error)) error { + if len(running) > 0 { + args := append([]string{"stop"}, running...) + if _, err := run(ctx, args...); err != nil { + if len(all) == 0 { + return errors.Errorf("failed to stop containers: %w", err) + } + deleteArgs := append([]string{"delete", "--force"}, all...) + if _, deleteErr := run(ctx, deleteArgs...); deleteErr != nil { + return errors.Errorf("failed to stop containers: %v; failed to delete containers: %w", err, deleteErr) + } + return nil + } + } + if len(all) > 0 { + args := append([]string{"delete", "--force"}, all...) + if _, err := run(ctx, args...); err != nil { + return errors.Errorf("failed to delete containers: %w", err) + } + } + return nil +} + +func appleListNetworks(ctx context.Context) ([]appleNetworkRecord, error) { + output, err := runContainerCommandOutput(ctx, "network", "list", "--format", "json") + if err != nil { + return nil, err + } + var records []appleNetworkRecord + if err := json.Unmarshal([]byte(output), &records); err != nil { + return nil, errors.Errorf("failed to decode network list: %w", err) + } + return records, nil +} + +func appleEnsureNetwork(ctx context.Context, name string, labels map[string]string) error { + if len(name) == 0 || name == "default" { + return nil + } + if output, err := runContainerCommandOutput(ctx, "network", "inspect", name); err == nil { + if hasAppleInspectRecords(output) { + return nil + } + } else if !isAppleNotFound(err) { + return err + } + args := []string{"network", "create"} + for _, key := range sortedKeys(labels) { + args = append(args, "--label", key+"="+labels[key]) + } + args = append(args, name) + if _, err := runContainerCommandOutput(ctx, args...); err != nil && !isAppleAlreadyExists(err) { + return err + } + return waitForAppleInspectReady(ctx, "network", "network", "inspect", name) +} + +func appleEnsureVolume(ctx context.Context, name string, labels map[string]string) error { + exists, err := appleVolumeExists(ctx, name) + if err != nil || exists { + return err + } + args := []string{"volume", "create"} + for _, key := range sortedKeys(labels) { + args = append(args, "--label", key+"="+labels[key]) + } + args = append(args, name) + _, err = runContainerCommandOutput(ctx, args...) + return err +} + +func appleEnsureImage(ctx context.Context, imageName string) error { + _, err := runContainerCommandOutput(ctx, "image", "inspect", imageName) + if err == nil { + return nil + } + if !isAppleImageNotFound(err) { + return err + } + return runContainerCommand(ctx, io.Discard, io.Discard, "image", "pull", imageName) +} + +func buildAppleContainerArgs(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, detach bool, remove bool) ([]string, error) { + applyContainerLabels(&config) + imageName := GetRegistryImageUrl(config.Image) + if err := appleEnsureImage(ctx, imageName); err != nil { + return nil, err + } + args := []string{"run"} + if detach { + args = append(args, "--detach") + } + if remove { + args = append(args, "--remove") + } + if len(containerName) > 0 { + args = append(args, "--name", containerName) + } + for _, key := range sortedKeys(config.Labels) { + args = append(args, "--label", key+"="+config.Labels[key]) + } + for _, item := range config.Env { + args = append(args, "--env", item) + } + if len(config.WorkingDir) > 0 { + args = append(args, "--workdir", config.WorkingDir) + } + if len(config.User) > 0 { + args = append(args, "--user", config.User) + } + if hostConfig.ReadonlyRootfs { + args = append(args, "--read-only") + } + if hostConfig.Resources.NanoCPUs > 0 { + args = append(args, "--cpus", strconv.FormatInt(hostConfig.Resources.NanoCPUs/1_000_000_000, 10)) + } + if hostConfig.Resources.Memory > 0 { + args = append(args, "--memory", strconv.FormatInt(hostConfig.Resources.Memory, 10)) + } + for path := range hostConfig.Tmpfs { + args = append(args, "--tmpfs", path) + } + networkName := hostConfig.NetworkMode.NetworkName() + if len(networkName) == 0 { + networkName = NetId + } + if err := appleEnsureNetwork(ctx, networkName, config.Labels); err != nil { + return nil, errors.Errorf("failed to create network: %w", err) + } + args = append(args, "--network", networkName) + mounts, err := buildAppleMounts(ctx, config.Labels, hostConfig) + if err != nil { + return nil, err + } + for _, item := range mounts { + args = append(args, "--mount", item) + } + for _, item := range buildApplePortBindings(hostConfig.PortBindings) { + args = append(args, "--publish", item) + } + if len(config.Entrypoint) > 0 { + args = append(args, "--entrypoint", config.Entrypoint[0]) + } + args = append(args, imageName) + switch { + case len(config.Entrypoint) > 0: + args = append(args, config.Entrypoint[1:]...) + args = append(args, config.Cmd...) + default: + args = append(args, config.Cmd...) + } + return args, nil +} + +func buildAppleMounts(ctx context.Context, labels map[string]string, hostConfig container.HostConfig) ([]string, error) { + var result []string + for _, bind := range hostConfig.Binds { + spec, err := loader.ParseVolume(bind) + if err != nil { + return nil, errors.Errorf("failed to parse docker volume: %w", err) + } + mountArg, err := dockerVolumeToAppleMount(ctx, labels, spec.Type, spec.Source, spec.Target, spec.ReadOnly) + if err != nil { + return nil, err + } + result = append(result, mountArg) + } + for _, source := range hostConfig.VolumesFrom { + info, err := appleInspectContainer(ctx, source) + if err != nil { + return nil, errors.Errorf("failed to inspect volumes-from container %s: %w", source, err) + } + for _, item := range info.Mounts { + mountArg := fmt.Sprintf("type=%s,source=%s,target=%s", item.Type, item.Source, item.Target) + if item.ReadOnly { + mountArg += ",readonly" + } + result = append(result, mountArg) + } + } + return RemoveDuplicates(result), nil +} + +func dockerVolumeToAppleMount(ctx context.Context, labels map[string]string, mountType, source, target string, readOnly bool) (string, error) { + if mountType == "volume" { + if err := appleEnsureVolume(ctx, source, labels); err != nil { + return "", errors.Errorf("failed to create volume: %w", err) + } + } + mountArg := fmt.Sprintf("type=%s,source=%s,target=%s", mountType, source, target) + if readOnly { + mountArg += ",readonly" + } + return mountArg, nil +} + +func buildApplePortBindings(bindings nat.PortMap) []string { + var result []string + ports := make([]string, 0, len(bindings)) + for port := range bindings { + ports = append(ports, string(port)) + } + sort.Strings(ports) + for _, key := range ports { + for _, binding := range bindings[nat.Port(key)] { + spec := "" + if len(binding.HostIP) > 0 { + spec += binding.HostIP + ":" + } + spec += binding.HostPort + ":" + nat.Port(key).Port() + if proto := nat.Port(key).Proto(); len(proto) > 0 { + spec += "/" + proto + } + result = append(result, spec) + } + } + return result +} + +func runContainerCommand(ctx context.Context, stdout, stderr io.Writer, args ...string) error { + cmd := execContainerCommand(ctx, "container", args...) + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return wrapAppleContainerError(err, nil) + } + return nil +} + +func runContainerCommandOutput(ctx context.Context, args ...string) (string, error) { + var stdout, stderr bytes.Buffer + cmd := execContainerCommand(ctx, "container", args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", wrapAppleContainerError(err, &stderr) + } + return strings.TrimSpace(stdout.String()), nil +} + +func wrapAppleContainerError(err error, stderr *bytes.Buffer) error { + if errors.Is(err, exec.ErrNotFound) { + CmdSuggestion = suggestAppleContainerInstall + return errors.Errorf("failed to run apple container CLI: %w", err) + } + if stderr == nil || stderr.Len() == 0 { + return err + } + return errors.New(strings.TrimSpace(stderr.String())) +} + +func isAppleNotFound(err error) bool { + msg := err.Error() + return strings.Contains(msg, "notFound") || strings.Contains(strings.ToLower(msg), "not found") +} + +func isAppleImageNotFound(err error) bool { + msg := err.Error() + return strings.Contains(msg, "Image not found") +} + +func (m appleMountRecord) mountTarget() string { + if len(m.Target) > 0 { + return m.Target + } + return m.Destination +} + +func (m appleMountRecord) mountType() string { + var kind string + if err := json.Unmarshal(m.Type, &kind); err == nil { + return kind + } + var typed map[string]json.RawMessage + if err := json.Unmarshal(m.Type, &typed); err == nil { + for kind := range typed { + return kind + } + } + return "" +} + +func (m appleMountRecord) isReadOnly() bool { + if m.ReadOnly { + return true + } + for _, option := range m.Options { + if strings.EqualFold(option, "readonly") || strings.EqualFold(option, "ro") { + return true + } + } + return false +} + +func isAppleAlreadyExists(err error) bool { + return strings.Contains(strings.ToLower(err.Error()), "already exists") +} + +func hasAppleInspectRecords(output string) bool { + output = strings.TrimSpace(output) + if len(output) == 0 || output == "[]" { + return false + } + var values []json.RawMessage + return json.Unmarshal([]byte(output), &values) == nil && len(values) > 0 +} + +func waitForAppleInspectReady(ctx context.Context, resource string, args ...string) error { + return waitForAppleReady(ctx, resource, func() (bool, error) { + output, err := runContainerCommandOutput(ctx, args...) + if err != nil { + if isAppleNotFound(err) { + return false, nil + } + return false, err + } + return hasAppleInspectRecords(output), nil + }) +} + +func waitForAppleReady(ctx context.Context, resource string, probe func() (bool, error)) error { + timeoutCtx, cancel := context.WithTimeout(ctx, appleResourceReadyTimeout) + defer cancel() + for { + ready, err := probe() + if err != nil { + return err + } + if ready { + return nil + } + select { + case <-timeoutCtx.Done(): + return errors.Errorf("%s was not ready in time: %w", resource, timeoutCtx.Err()) + case <-time.After(appleResourceReadyInterval): + } + } +} + +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/utils/apple_container_test.go b/internal/utils/apple_container_test.go new file mode 100644 index 0000000000..786f5d0c7c --- /dev/null +++ b/internal/utils/apple_container_test.go @@ -0,0 +1,198 @@ +package utils + +import ( + "context" + "encoding/json" + stderrors "errors" + "os/exec" + "strings" + "testing" + "time" + + "github.com/containerd/errdefs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHasAppleInspectRecords(t *testing.T) { + t.Run("returns false for empty results", func(t *testing.T) { + assert.False(t, hasAppleInspectRecords("")) + assert.False(t, hasAppleInspectRecords("[]")) + }) + + t.Run("returns true for populated results", func(t *testing.T) { + assert.True(t, hasAppleInspectRecords(`[{"id":"supabase-network-demo"}]`)) + }) + + t.Run("returns false for invalid json", func(t *testing.T) { + assert.False(t, hasAppleInspectRecords("not-json")) + }) +} + +func TestAppleMountRecord(t *testing.T) { + t.Run("parses object mount type", func(t *testing.T) { + record := appleMountRecord{ + Destination: "/var/lib/postgresql/data", + Type: json.RawMessage(`{"volume":{"name":"supabase-db-demo"}}`), + } + + assert.Equal(t, "/var/lib/postgresql/data", record.mountTarget()) + assert.Equal(t, "volume", record.mountType()) + assert.False(t, record.isReadOnly()) + }) + + t.Run("parses string mount type and readonly option", func(t *testing.T) { + record := appleMountRecord{ + Target: "/data", + Type: json.RawMessage(`"bind"`), + Options: []string{"readonly"}, + } + + assert.Equal(t, "/data", record.mountTarget()) + assert.Equal(t, "bind", record.mountType()) + assert.True(t, record.isReadOnly()) + }) +} + +func TestWaitForAppleReady(t *testing.T) { + t.Run("retries until resource is ready", func(t *testing.T) { + attempts := 0 + + err := waitForAppleReady(context.Background(), "network", func() (bool, error) { + attempts++ + return attempts == 3, nil + }) + + require.NoError(t, err) + assert.Equal(t, 3, attempts) + }) + + t.Run("returns probe error", func(t *testing.T) { + probeErr := stderrors.New("boom") + attempts := 0 + + err := waitForAppleReady(context.Background(), "network", func() (bool, error) { + attempts++ + if attempts == 2 { + return false, probeErr + } + return false, nil + }) + + require.ErrorIs(t, err, probeErr) + }) + + t.Run("returns timeout error", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := waitForAppleReady(ctx, "network", func() (bool, error) { + return false, nil + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "network was not ready in time") + }) +} + +func TestAppleStopAndDeleteContainers(t *testing.T) { + t.Run("falls back to force delete when stop times out", func(t *testing.T) { + var calls [][]string + run := func(_ context.Context, args ...string) (string, error) { + calls = append(calls, append([]string(nil), args...)) + if len(args) > 0 && args[0] == "stop" { + return "", stderrors.New(`internalError: "failed to stop container"`) + } + return "", nil + } + + err := appleStopAndDeleteContainers(context.Background(), []string{"db"}, []string{"db", "rest"}, run) + + require.NoError(t, err) + require.Len(t, calls, 2) + assert.Equal(t, []string{"stop", "db"}, calls[0]) + assert.Equal(t, []string{"delete", "--force", "db", "rest"}, calls[1]) + }) + + t.Run("returns both errors when stop and force delete fail", func(t *testing.T) { + run := func(_ context.Context, args ...string) (string, error) { + if len(args) > 0 && args[0] == "stop" { + return "", stderrors.New("stop timeout") + } + return "", stderrors.New("delete failed") + } + + err := appleStopAndDeleteContainers(context.Background(), []string{"db"}, []string{"db"}, run) + + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "failed to stop containers")) + assert.True(t, strings.Contains(err.Error(), "failed to delete containers")) + }) +} + +func TestAppleRestartContainerWithRun(t *testing.T) { + t.Run("stops then starts container", func(t *testing.T) { + var calls [][]string + run := func(_ context.Context, args ...string) (string, error) { + calls = append(calls, append([]string(nil), args...)) + return "", nil + } + + err := appleRestartContainerWithRun(context.Background(), "db", run) + + require.NoError(t, err) + assert.Equal(t, [][]string{{"stop", "db"}, {"start", "db"}}, calls) + }) + + t.Run("maps not found to errdefs", func(t *testing.T) { + run := func(_ context.Context, args ...string) (string, error) { + return "", stderrors.New("notFound: no such container") + } + + err := appleRestartContainerWithRun(context.Background(), "db", run) + + require.ErrorIs(t, err, errdefs.ErrNotFound) + }) +} + +func TestAppleRemoveVolumeWithRun(t *testing.T) { + t.Run("deletes named volume", func(t *testing.T) { + var calls [][]string + run := func(_ context.Context, args ...string) (string, error) { + calls = append(calls, append([]string(nil), args...)) + return "", nil + } + + err := appleRemoveVolumeWithRun(context.Background(), "db-volume", true, run) + + require.NoError(t, err) + assert.Equal(t, [][]string{{"volume", "delete", "db-volume"}}, calls) + }) + + t.Run("maps missing volume to errdefs", func(t *testing.T) { + run := func(_ context.Context, args ...string) (string, error) { + return "", stderrors.New("volume not found") + } + + err := appleRemoveVolumeWithRun(context.Background(), "db-volume", true, run) + + require.ErrorIs(t, err, errdefs.ErrNotFound) + }) +} + +func TestAppleRemoveContainer(t *testing.T) { + t.Run("maps missing container to errdefs", func(t *testing.T) { + originalExec := execContainerCommand + t.Cleanup(func() { + execContainerCommand = originalExec + }) + + execContainerCommand = func(context.Context, string, ...string) *exec.Cmd { + return exec.Command("sh", "-c", "echo 'not found' 1>&2; exit 1") + } + + err := appleRemoveContainer(context.Background(), "missing", true) + + require.ErrorIs(t, err, errdefs.ErrNotFound) + }) +} diff --git a/internal/utils/config.go b/internal/utils/config.go index 108c3391a1..19a009ec10 100644 --- a/internal/utils/config.go +++ b/internal/utils/config.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "strconv" + "strings" "github.com/compose-spec/compose-go/v2/types" "github.com/go-errors/errors" @@ -55,9 +56,37 @@ var ( ) func GetId(name string) string { + if UsesAppleContainerRuntime() { + return "supabase-" + normalizeAppleContainerName(name) + "-" + normalizeAppleContainerName(Config.ProjectId) + } return "supabase_" + name + "_" + Config.ProjectId } +func UsesAppleContainerRuntime() bool { + return Config.Local.Runtime == config.AppleContainerRuntime +} + +func UsesDockerRuntime() bool { + return !UsesAppleContainerRuntime() +} + +func RuntimeServiceHost(alias, containerId string) string { + if UsesAppleContainerRuntime() { + return containerId + } + return alias +} + +func normalizeAppleContainerName(value string) string { + replacer := strings.NewReplacer("_", "-", ".", "-", " ", "-") + value = strings.ToLower(replacer.Replace(value)) + value = strings.Trim(value, "-") + if len(value) == 0 { + return "default" + } + return value +} + func UpdateDockerIds() { if NetId = viper.GetString("network-id"); len(NetId) == 0 { NetId = GetId("network") diff --git a/internal/utils/config_test.go b/internal/utils/config_test.go index 9ac835170f..d26aae36dd 100644 --- a/internal/utils/config_test.go +++ b/internal/utils/config_test.go @@ -8,10 +8,18 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + configpkg "github.com/supabase/cli/pkg/config" ) func TestGetId(t *testing.T) { + t.Cleanup(func() { + Config.Local.Runtime = configpkg.DockerRuntime + Config.ProjectId = "" + UpdateDockerIds() + }) + t.Run("generates container id", func(t *testing.T) { + Config.Local.Runtime = configpkg.DockerRuntime Config.ProjectId = "test-project" name := "test-service" @@ -19,10 +27,28 @@ func TestGetId(t *testing.T) { assert.Equal(t, "supabase_test-service_test-project", id) }) + + t.Run("generates apple container id", func(t *testing.T) { + Config.Local.Runtime = configpkg.AppleContainerRuntime + Config.ProjectId = "test-project" + name := "edge_runtime" + + id := GetId(name) + + assert.Equal(t, "supabase-edge-runtime-test-project", id) + }) } func TestUpdateDockerIds(t *testing.T) { + t.Cleanup(func() { + viper.Reset() + Config.Local.Runtime = configpkg.DockerRuntime + Config.ProjectId = "" + UpdateDockerIds() + }) + t.Run("updates all container ids", func(t *testing.T) { + Config.Local.Runtime = configpkg.DockerRuntime Config.ProjectId = "test-project" viper.Set("network-id", "custom-network") defer viper.Reset() @@ -48,6 +74,7 @@ func TestUpdateDockerIds(t *testing.T) { }) t.Run("generates network id if not set", func(t *testing.T) { + Config.Local.Runtime = configpkg.DockerRuntime Config.ProjectId = "test-project" viper.Reset() @@ -55,6 +82,41 @@ func TestUpdateDockerIds(t *testing.T) { assert.Equal(t, "supabase_network_test-project", NetId) }) + + t.Run("updates all container ids for apple container runtime", func(t *testing.T) { + Config.Local.Runtime = configpkg.AppleContainerRuntime + Config.ProjectId = "test-project" + viper.Reset() + + UpdateDockerIds() + + assert.Equal(t, "supabase-network-test-project", NetId) + assert.Equal(t, "supabase-db-test-project", DbId) + assert.Equal(t, "supabase-edge-runtime-test-project", EdgeRuntimeId) + assert.Equal(t, "supabase-pooler-test-project", PoolerId) + }) +} + +func TestRuntimeServiceHost(t *testing.T) { + t.Cleanup(func() { + Config.Local.Runtime = configpkg.DockerRuntime + }) + + t.Run("uses alias on docker runtime", func(t *testing.T) { + Config.Local.Runtime = configpkg.DockerRuntime + + host := RuntimeServiceHost("db", "supabase-db-test") + + assert.Equal(t, "db", host) + }) + + t.Run("uses container id on apple runtime", func(t *testing.T) { + Config.Local.Runtime = configpkg.AppleContainerRuntime + + host := RuntimeServiceHost("db", "supabase-db-test") + + assert.Equal(t, "supabase-db-test", host) + }) } func TestInitConfig(t *testing.T) { diff --git a/internal/utils/docker.go b/internal/utils/docker.go index 6da0d5aa65..d5a772967f 100644 --- a/internal/utils/docker.go +++ b/internal/utils/docker.go @@ -93,7 +93,7 @@ func WaitAll[T any](containers []T, exec func(container T) error) []error { // NoBackupVolume TODO: encapsulate this state in a class var NoBackupVolume = false -func DockerRemoveAll(ctx context.Context, w io.Writer, projectId string) error { +func dockerRemoveAll(ctx context.Context, w io.Writer, projectId string) error { fmt.Fprintln(w, "Stopping containers...") args := CliProjectFilter(projectId) containers, err := Docker.ContainerList(ctx, container.ListOptions{ @@ -247,9 +247,9 @@ func DockerPullImageIfNotCached(ctx context.Context, imageName string) error { return DockerImagePullWithRetry(ctx, imageUrl, 2) } -var suggestDockerInstall = "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop" +var suggestDockerInstall = "Docker Desktop is required when using the docker runtime for local development. Follow the official docs to install: https://docs.docker.com/desktop" -func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) { +func dockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) { // Pull container image if err := DockerPullImageIfNotCached(ctx, config.Image); err != nil { if client.IsErrConnectionFailed(err) { @@ -259,11 +259,7 @@ func DockerStart(ctx context.Context, config container.Config, hostConfig contai } // Setup default config config.Image = GetRegistryImageUrl(config.Image) - if config.Labels == nil { - config.Labels = make(map[string]string, 2) - } - config.Labels[CliProjectLabel] = Config.ProjectId - config.Labels[composeProjectLabel] = Config.ProjectId + applyContainerLabels(&config) // Configure container network hostConfig.ExtraHosts = append(hostConfig.ExtraHosts, extraHosts...) if networkId := viper.GetString("network-id"); len(networkId) > 0 { @@ -329,7 +325,7 @@ func DockerStart(ctx context.Context, config container.Config, hostConfig contai return resp.ID, err } -func DockerRemove(containerId string) { +func dockerRemove(containerId string) { if err := Docker.ContainerRemove(context.Background(), containerId, container.RemoveOptions{ RemoveVolumes: true, Force: true, @@ -364,21 +360,21 @@ func DockerRunOnceWithStream(ctx context.Context, image string, env, cmd []strin }, container.HostConfig{}, network.NetworkingConfig{}, "", stdout, stderr) } -func DockerRunOnceWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { +func dockerRunOnceWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { // Cannot rely on docker's auto remove because // 1. We must inspect exit code after container stops // 2. Context cancellation may happen after start - container, err := DockerStart(ctx, config, hostConfig, networkingConfig, containerName) + container, err := dockerStart(ctx, config, hostConfig, networkingConfig, containerName) if err != nil { return err } - defer DockerRemove(container) - return DockerStreamLogs(ctx, container, stdout, stderr) + defer dockerRemove(container) + return dockerStreamLogs(ctx, container, stdout, stderr) } var ErrContainerKilled = errors.New("exit 137") -func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer, opts ...func(*container.LogsOptions)) error { +func dockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer, opts ...func(*container.LogsOptions)) error { logsOptions := container.LogsOptions{ ShowStdout: true, ShowStderr: true, @@ -412,7 +408,7 @@ func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io return errors.Errorf("error running container: %w", err) } -func DockerStreamLogsOnce(ctx context.Context, containerId string, stdout, stderr io.Writer) error { +func dockerStreamLogsOnce(ctx context.Context, containerId string, stdout, stderr io.Writer) error { logs, err := Docker.ContainerLogs(ctx, containerId, container.LogsOptions{ ShowStdout: true, ShowStderr: true, @@ -434,11 +430,11 @@ func DockerExecOnce(ctx context.Context, containerId string, env []string, cmd [ stderr = os.Stderr } var out bytes.Buffer - err := DockerExecOnceWithStream(ctx, containerId, "", env, cmd, &out, stderr) + err := dockerExecOnceWithStream(ctx, containerId, "", env, cmd, &out, stderr) return out.String(), err } -func DockerExecOnceWithStream(ctx context.Context, containerId, workdir string, env, cmd []string, stdout, stderr io.Writer) error { +func dockerExecOnceWithStream(ctx context.Context, containerId, workdir string, env, cmd []string, stdout, stderr io.Writer) error { // Reset shadow database exec, err := Docker.ContainerExecCreate(ctx, containerId, container.ExecOptions{ Env: env, diff --git a/internal/utils/flags/config_path.go b/internal/utils/flags/config_path.go index 4df1bff746..847b19ca17 100644 --- a/internal/utils/flags/config_path.go +++ b/internal/utils/flags/config_path.go @@ -4,7 +4,9 @@ import ( "strings" "github.com/spf13/afero" + "github.com/spf13/viper" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" ) func LoadConfig(fsys afero.Fs) error { @@ -12,6 +14,13 @@ func LoadConfig(fsys afero.Fs) error { if err := utils.Config.Load("", utils.NewRootFS(fsys)); err != nil { return err } + if runtime := viper.GetString("runtime"); len(runtime) > 0 { + var value config.LocalRuntime + if err := value.UnmarshalText([]byte(runtime)); err != nil { + return err + } + utils.Config.Local.Runtime = value + } utils.UpdateDockerIds() // Apply profile specific overrides if strings.EqualFold(utils.CurrentProfile.Name, "snap") { diff --git a/internal/utils/flags/config_path_test.go b/internal/utils/flags/config_path_test.go new file mode 100644 index 0000000000..63152ee075 --- /dev/null +++ b/internal/utils/flags/config_path_test.go @@ -0,0 +1,51 @@ +package flags + +import ( + "strings" + "testing" + + "github.com/spf13/afero" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/utils" + configpkg "github.com/supabase/cli/pkg/config" +) + +func TestLoadConfigRuntimeSelection(t *testing.T) { + t.Cleanup(func() { + viper.Reset() + ProjectRef = "" + utils.Config.Local.Runtime = configpkg.DockerRuntime + utils.UpdateDockerIds() + }) + + t.Run("uses runtime from config file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, utils.InitConfig(utils.InitParams{ProjectId: "test-project"}, fsys)) + + content, err := afero.ReadFile(fsys, utils.ConfigPath) + require.NoError(t, err) + updated := strings.Replace(string(content), `runtime = "docker"`, `runtime = "apple-container"`, 1) + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte(updated), 0644)) + + require.NoError(t, LoadConfig(fsys)) + assert.Equal(t, configpkg.AppleContainerRuntime, utils.Config.Local.Runtime) + assert.Equal(t, "supabase-db-test-project", utils.DbId) + }) + + t.Run("flag overrides runtime from config file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, utils.InitConfig(utils.InitParams{ProjectId: "test-project"}, fsys)) + + content, err := afero.ReadFile(fsys, utils.ConfigPath) + require.NoError(t, err) + updated := strings.Replace(string(content), `runtime = "docker"`, `runtime = "apple-container"`, 1) + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte(updated), 0644)) + + viper.Set("runtime", "docker") + require.NoError(t, LoadConfig(fsys)) + assert.Equal(t, configpkg.DockerRuntime, utils.Config.Local.Runtime) + assert.Equal(t, "supabase_db_test-project", utils.DbId) + }) +} diff --git a/internal/utils/misc.go b/internal/utils/misc.go index e0518acc7f..7e6530608b 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -125,15 +125,23 @@ func AssertSupabaseDbIsRunning() error { } func AssertServiceIsRunning(ctx context.Context, containerId string) error { - if _, err := Docker.ContainerInspect(ctx, containerId); err != nil { + info, err := InspectContainer(ctx, containerId) + if err != nil { if errdefs.IsNotFound(err) { return errors.New(ErrNotRunning) } if client.IsErrConnectionFailed(err) { - CmdSuggestion = suggestDockerInstall + if UsesAppleContainerRuntime() { + CmdSuggestion = suggestAppleContainerInstall + } else { + CmdSuggestion = suggestDockerInstall + } } return errors.Errorf("failed to inspect service: %w", err) } + if UsesAppleContainerRuntime() && !info.Running { + return errors.New(ErrNotRunning) + } return nil } diff --git a/internal/utils/runtime.go b/internal/utils/runtime.go new file mode 100644 index 0000000000..dd68baf24b --- /dev/null +++ b/internal/utils/runtime.go @@ -0,0 +1,459 @@ +package utils + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "strings" + + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/volume" + "github.com/docker/docker/client" + "github.com/go-errors/errors" +) + +const healthcheckLabel = "com.supabase.cli.healthcheck" + +type ContainerMount struct { + Source string + Target string + Type string + ReadOnly bool +} + +type ContainerInfo struct { + ID string + Names []string + Labels map[string]string + Status string + Running bool + HealthStatus string + Mounts []ContainerMount + NetworkIPs map[string]string +} + +type VolumeInfo struct { + Name string + Labels map[string]string +} + +type NetworkInfo struct { + Name string + Labels map[string]string +} + +func applyContainerLabels(config *container.Config) { + if config.Labels == nil { + config.Labels = make(map[string]string, 3) + } + config.Labels[CliProjectLabel] = Config.ProjectId + config.Labels[composeProjectLabel] = Config.ProjectId + if encoded := encodeHealthcheck(config.Healthcheck); len(encoded) > 0 { + config.Labels[healthcheckLabel] = encoded + } +} + +func encodeHealthcheck(check *container.HealthConfig) string { + if check == nil || len(check.Test) == 0 { + return "" + } + payload, err := json.Marshal(check.Test) + if err != nil { + return "" + } + // Apple container labels reject "=" padding in values. + return base64.RawStdEncoding.EncodeToString(payload) +} + +func decodeHealthcheck(encoded string) ([]string, error) { + var payload []byte + var err error + for _, value := range []string{encoded, strings.TrimRight(encoded, "=")} { + payload, err = base64.StdEncoding.DecodeString(value) + if err == nil { + break + } + payload, err = base64.RawStdEncoding.DecodeString(value) + if err == nil { + break + } + payload, err = base64.URLEncoding.DecodeString(value) + if err == nil { + break + } + payload, err = base64.RawURLEncoding.DecodeString(value) + if err == nil { + break + } + } + if err != nil { + return nil, err + } + var test []string + if err := json.Unmarshal(payload, &test); err != nil { + return nil, err + } + return test, nil +} + +func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) { + if UsesAppleContainerRuntime() { + return appleStart(ctx, config, hostConfig, networkingConfig, containerName) + } + return dockerStart(ctx, config, hostConfig, networkingConfig, containerName) +} + +func DockerRemoveAll(ctx context.Context, w io.Writer, projectId string) error { + if UsesAppleContainerRuntime() { + return appleRemoveAll(ctx, w, projectId) + } + return dockerRemoveAll(ctx, w, projectId) +} + +func DockerRemove(containerId string) { + if UsesAppleContainerRuntime() { + _ = appleRemoveContainer(context.Background(), containerId, true) + return + } + dockerRemove(containerId) +} + +func DockerRunOnceWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error { + if UsesAppleContainerRuntime() { + return appleRunOnceWithConfig(ctx, config, hostConfig, networkingConfig, containerName, stdout, stderr) + } + return dockerRunOnceWithConfig(ctx, config, hostConfig, networkingConfig, containerName, stdout, stderr) +} + +func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer, opts ...func(*container.LogsOptions)) error { + if UsesAppleContainerRuntime() { + return appleStreamLogs(ctx, containerId, stdout, stderr) + } + return dockerStreamLogs(ctx, containerId, stdout, stderr, opts...) +} + +func DockerStreamLogsOnce(ctx context.Context, containerId string, stdout, stderr io.Writer) error { + if UsesAppleContainerRuntime() { + return appleStreamLogsOnce(ctx, containerId, stdout, stderr) + } + return dockerStreamLogsOnce(ctx, containerId, stdout, stderr) +} + +func DockerExecOnceWithStream(ctx context.Context, containerId, workdir string, env, cmd []string, stdout, stderr io.Writer) error { + if UsesAppleContainerRuntime() { + return appleExecOnceWithStream(ctx, containerId, workdir, env, cmd, stdout, stderr) + } + return dockerExecOnceWithStream(ctx, containerId, workdir, env, cmd, stdout, stderr) +} + +func RemoveContainer(ctx context.Context, containerId string, removeVolumes, force bool) error { + if UsesAppleContainerRuntime() { + return appleRemoveContainer(ctx, containerId, force) + } + return Docker.ContainerRemove(ctx, containerId, container.RemoveOptions{ + RemoveVolumes: removeVolumes, + Force: force, + }) +} + +func RemoveVolume(ctx context.Context, volumeName string, force bool) error { + if UsesAppleContainerRuntime() { + return appleRemoveVolume(ctx, volumeName, force) + } + return Docker.VolumeRemove(ctx, volumeName, force) +} + +func RestartContainer(ctx context.Context, containerId string) error { + if UsesAppleContainerRuntime() { + return appleRestartContainer(ctx, containerId) + } + return Docker.ContainerRestart(ctx, containerId, container.StopOptions{}) +} + +func InspectContainer(ctx context.Context, containerId string) (ContainerInfo, error) { + if UsesAppleContainerRuntime() { + return appleInspectContainer(ctx, containerId) + } + resp, err := Docker.ContainerInspect(ctx, containerId) + if err != nil { + return ContainerInfo{}, err + } + name := "" + if resp.ContainerJSONBase != nil { + name = resp.Name + } + info := ContainerInfo{ + ID: name, + Labels: map[string]string{}, + Status: "", + Running: false, + Names: nil, + NetworkIPs: map[string]string{}, + } + if len(name) > 0 { + info.Names = []string{name} + } + if len(info.ID) > 0 && info.ID[0] == '/' { + info.ID = info.ID[1:] + info.Names = []string{name} + } + if resp.Config != nil && resp.Config.Labels != nil { + info.Labels = resp.Config.Labels + } + if resp.ContainerJSONBase != nil && resp.State != nil { + info.Status = resp.State.Status + info.Running = resp.State.Running + if resp.State.Health != nil { + info.HealthStatus = string(resp.State.Health.Status) + } + } + if resp.NetworkSettings != nil { + for name, details := range resp.NetworkSettings.Networks { + if details != nil && len(details.IPAddress) > 0 { + info.NetworkIPs[name] = details.IPAddress + } + } + } + if len(resp.Mounts) > 0 { + info.Mounts = make([]ContainerMount, 0, len(resp.Mounts)) + for _, m := range resp.Mounts { + info.Mounts = append(info.Mounts, ContainerMount{ + Source: m.Name, + Target: m.Destination, + Type: string(m.Type), + ReadOnly: !m.RW, + }) + } + } + return info, nil +} + +func GetContainerIP(ctx context.Context, containerId, networkName string) (string, error) { + info, err := InspectContainer(ctx, containerId) + if err != nil { + return "", err + } + if len(networkName) > 0 { + if ip, ok := info.NetworkIPs[networkName]; ok && len(ip) > 0 { + return strings.TrimSuffix(ip, "/24"), nil + } + } + for _, ip := range info.NetworkIPs { + if len(ip) > 0 { + return strings.TrimSuffix(ip, "/24"), nil + } + } + return "", errors.Errorf("failed to detect IP address for container: %s", containerId) +} + +func ListContainers(ctx context.Context, all bool) ([]ContainerInfo, error) { + if UsesAppleContainerRuntime() { + return appleListContainers(ctx, all) + } + resp, err := Docker.ContainerList(ctx, container.ListOptions{All: all}) + if err != nil { + return nil, err + } + result := make([]ContainerInfo, 0, len(resp)) + for _, item := range resp { + id := item.ID + if len(id) == 0 && len(item.Names) > 0 { + id = item.Names[0] + } + if len(id) > 0 && id[0] == '/' { + id = id[1:] + } + result = append(result, ContainerInfo{ + ID: id, + Names: item.Names, + Labels: item.Labels, + Status: item.State, + Running: item.State == "running", + }) + } + return result, nil +} + +func ListVolumes(ctx context.Context) ([]VolumeInfo, error) { + if UsesAppleContainerRuntime() { + return appleListVolumes(ctx) + } + resp, err := Docker.VolumeList(ctx, volume.ListOptions{}) + if err != nil { + return nil, err + } + result := make([]VolumeInfo, 0, len(resp.Volumes)) + for _, item := range resp.Volumes { + result = append(result, VolumeInfo{Name: item.Name, Labels: item.Labels}) + } + return result, nil +} + +func ListNetworks(ctx context.Context) ([]NetworkInfo, error) { + if UsesAppleContainerRuntime() { + resp, err := appleListNetworks(ctx) + if err != nil { + return nil, err + } + result := make([]NetworkInfo, 0, len(resp)) + for _, item := range resp { + result = append(result, NetworkInfo{Name: item.ID, Labels: item.Config.Labels}) + } + return result, nil + } + resp, err := Docker.NetworkList(ctx, network.ListOptions{}) + if err != nil { + return nil, err + } + result := make([]NetworkInfo, 0, len(resp)) + for _, item := range resp { + result = append(result, NetworkInfo{Name: item.Name, Labels: item.Labels}) + } + return result, nil +} + +func VolumeExists(ctx context.Context, name string) (bool, error) { + if UsesAppleContainerRuntime() { + return appleVolumeExists(ctx, name) + } + if _, err := Docker.VolumeInspect(ctx, name); err == nil { + return true, nil + } else if errdefs.IsNotFound(err) { + return false, nil + } else { + return false, err + } +} + +func AssertServiceHealthy(ctx context.Context, containerId string) error { + info, err := InspectContainer(ctx, containerId) + if err != nil { + if errdefs.IsNotFound(err) { + return errors.New(ErrNotRunning) + } + if client.IsErrConnectionFailed(err) { + if UsesAppleContainerRuntime() { + CmdSuggestion = suggestAppleContainerInstall + } else { + CmdSuggestion = suggestDockerInstall + } + } + return errors.Errorf("failed to inspect service: %w", err) + } + if !info.Running { + return errors.Errorf("%s container is not running: %s", containerId, info.Status) + } + if UsesAppleContainerRuntime() { + if encoded, ok := info.Labels[healthcheckLabel]; ok && len(encoded) > 0 { + test, err := decodeHealthcheck(encoded) + if err != nil { + return errors.Errorf("failed to decode service healthcheck: %w", err) + } + if err := appleRunHealthcheck(ctx, containerId, test); err != nil { + return errors.Errorf("%s container is not ready: %w", containerId, err) + } + } + return nil + } + if len(info.HealthStatus) > 0 && info.HealthStatus != string(types.Healthy) { + return errors.Errorf("%s container is not ready: %s", containerId, info.HealthStatus) + } + return nil +} + +func ListProjectVolumes(ctx context.Context, projectId string) ([]VolumeInfo, error) { + volumes, err := ListVolumes(ctx) + if err != nil { + return nil, err + } + var result []VolumeInfo + for _, item := range volumes { + if matchesProjectLabel(item.Labels, projectId) || matchesProjectName(item.Name, projectId) { + result = append(result, item) + } + } + return result, nil +} + +func ListProjectNetworks(ctx context.Context, projectId string) ([]NetworkInfo, error) { + networks, err := ListNetworks(ctx) + if err != nil { + return nil, err + } + var result []NetworkInfo + for _, item := range networks { + if matchesProjectLabel(item.Labels, projectId) || matchesProjectName(item.Name, projectId) { + result = append(result, item) + } + } + return result, nil +} + +func ListProjectContainers(ctx context.Context, projectId string, all bool) ([]ContainerInfo, error) { + containers, err := ListContainers(ctx, all) + if err != nil { + return nil, err + } + var result []ContainerInfo + for _, item := range containers { + if matchesProjectLabel(item.Labels, projectId) || matchesProjectContainer(item, projectId) { + result = append(result, item) + } + } + return result, nil +} + +func matchesProjectLabel(labels map[string]string, projectId string) bool { + if len(labels) == 0 { + return false + } + value, ok := labels[CliProjectLabel] + if !ok { + return false + } + return len(projectId) == 0 || value == projectId +} + +func matchesProjectContainer(info ContainerInfo, projectId string) bool { + if matchesProjectName(info.ID, projectId) { + return true + } + for _, name := range info.Names { + if matchesProjectName(name, projectId) { + return true + } + } + return false +} + +func matchesProjectName(name, projectId string) bool { + if len(projectId) == 0 || len(name) == 0 { + return false + } + trimmed := strings.TrimPrefix(name, "/") + return strings.HasSuffix(trimmed, "_"+projectId) || strings.HasSuffix(trimmed, "-"+projectId) +} + +func filterProjectVolumes(volumes []VolumeInfo, projectId string) []VolumeInfo { + var result []VolumeInfo + for _, item := range volumes { + if matchesProjectLabel(item.Labels, projectId) { + result = append(result, item) + } + } + return result +} + +func filterProjectContainers(containers []ContainerInfo, projectId string) []ContainerInfo { + var result []ContainerInfo + for _, item := range containers { + if matchesProjectLabel(item.Labels, projectId) { + result = append(result, item) + } + } + return result +} diff --git a/internal/utils/runtime_test.go b/internal/utils/runtime_test.go new file mode 100644 index 0000000000..49b5bde4a6 --- /dev/null +++ b/internal/utils/runtime_test.go @@ -0,0 +1,49 @@ +package utils + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeHealthcheck(t *testing.T) { + testCmd := []string{"CMD", "pg_isready", "-U", "postgres"} + payload, err := json.Marshal(testCmd) + require.NoError(t, err) + + t.Run("decodes padded base64", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString(payload) + + decoded, err := decodeHealthcheck(encoded) + + require.NoError(t, err) + assert.Equal(t, testCmd, decoded) + }) + + t.Run("decodes unpadded base64", func(t *testing.T) { + encoded := strings.TrimRight(base64.StdEncoding.EncodeToString(payload), "=") + + decoded, err := decodeHealthcheck(encoded) + + require.NoError(t, err) + assert.Equal(t, testCmd, decoded) + }) +} + +func TestEncodeHealthcheck(t *testing.T) { + encoded := encodeHealthcheck(&container.HealthConfig{ + Test: []string{"CMD", "curl", "-sSfL", "http://127.0.0.1:4000/health"}, + }) + + assert.NotEmpty(t, encoded) + assert.NotContains(t, encoded, "=") + + decoded, err := decodeHealthcheck(encoded) + require.NoError(t, err) + assert.Equal(t, []string{"CMD", "curl", "-sSfL", "http://127.0.0.1:4000/health"}, decoded) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 018528a50a..de0b0fcfa5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -95,6 +95,21 @@ func (p *RequestPolicy) UnmarshalText(text []byte) error { return nil } +type LocalRuntime string + +const ( + DockerRuntime LocalRuntime = "docker" + AppleContainerRuntime LocalRuntime = "apple-container" +) + +func (r *LocalRuntime) UnmarshalText(text []byte) error { + allowed := []LocalRuntime{DockerRuntime, AppleContainerRuntime} + if *r = LocalRuntime(text); !slices.Contains(allowed, *r) { + return errors.Errorf("must be one of %v", allowed) + } + return nil +} + type Glob []string // Match the glob patterns in the given FS to get a deduplicated @@ -155,9 +170,14 @@ type ( config struct { baseConfig + Local local `toml:"local" json:"local"` Remotes map[string]baseConfig `toml:"remotes" json:"remotes"` } + local struct { + Runtime LocalRuntime `toml:"runtime" json:"runtime"` + } + realtime struct { Enabled bool `toml:"enabled" json:"enabled"` Image string `toml:"-" json:"-"` @@ -340,96 +360,101 @@ func WithHostname(hostname string) ConfigEditor { } func NewConfig(editors ...ConfigEditor) config { - initial := config{baseConfig: baseConfig{ - Hostname: "127.0.0.1", - Api: api{ - Image: Images.Postgrest, - KongImage: Images.Kong, - Tls: tlsKong{ - CertContent: kongCert, - KeyContent: kongKey, + initial := config{ + baseConfig: baseConfig{ + Hostname: "127.0.0.1", + Api: api{ + Image: Images.Postgrest, + KongImage: Images.Kong, + Tls: tlsKong{ + CertContent: kongCert, + KeyContent: kongKey, + }, }, - }, - Db: db{ - Image: Images.Pg, - Password: "postgres", - RootKey: Secret{ - Value: "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + Db: db{ + Image: Images.Pg, + Password: "postgres", + RootKey: Secret{ + Value: "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275", + }, + Pooler: pooler{ + Image: Images.Supavisor, + TenantId: "pooler-dev", + EncryptionKey: "12345678901234567890123456789032", + SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + }, + Migrations: migrations{ + Enabled: true, + }, + Seed: seed{ + Enabled: true, + SqlPaths: []string{"seed.sql"}, + }, }, - Pooler: pooler{ - Image: Images.Supavisor, - TenantId: "pooler-dev", - EncryptionKey: "12345678901234567890123456789032", - SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + Realtime: realtime{ + Image: Images.Realtime, + IpVersion: AddressIPv4, + MaxHeaderLength: 4096, + TenantId: "realtime-dev", + EncryptionKey: "supabaserealtime", + SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", }, - Migrations: migrations{ - Enabled: true, + Storage: storage{ + Image: Images.Storage, + ImgProxyImage: Images.ImgProxy, + S3Credentials: storageS3Credentials{ + AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c", + SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + Region: "local", + }, }, - Seed: seed{ - Enabled: true, - SqlPaths: []string{"seed.sql"}, + Auth: auth{ + Image: Images.Gotrue, + Email: email{ + Template: map[string]emailTemplate{}, + Notification: map[string]notification{}, + }, + Sms: sms{ + TestOTP: map[string]string{}, + }, + External: map[string]provider{}, + SigningKeys: []JWK{{ + KeyType: "EC", + KeyID: "b81269f1-21d8-4f2e-b719-c2240a840d90", + Use: "sig", + KeyOps: []string{"sign", "verify"}, + Algorithm: "ES256", + Extractable: cast.Ptr(true), + Curve: "P-256", + X: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + Y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + PrivateExponent: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", + }}, }, - }, - Realtime: realtime{ - Image: Images.Realtime, - IpVersion: AddressIPv4, - MaxHeaderLength: 4096, - TenantId: "realtime-dev", - EncryptionKey: "supabaserealtime", - SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - }, - Storage: storage{ - Image: Images.Storage, - ImgProxyImage: Images.ImgProxy, - S3Credentials: storageS3Credentials{ - AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c", - SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", - Region: "local", + Inbucket: inbucket{ + Image: Images.Inbucket, + AdminEmail: "admin@email.com", + SenderName: "Admin", }, - }, - Auth: auth{ - Image: Images.Gotrue, - Email: email{ - Template: map[string]emailTemplate{}, - Notification: map[string]notification{}, + Studio: studio{ + Image: Images.Studio, + PgmetaImage: Images.Pgmeta, }, - Sms: sms{ - TestOTP: map[string]string{}, + Analytics: analytics{ + Image: Images.Logflare, + VectorImage: Images.Vector, + ApiKey: "api-key", + // Defaults to bigquery for backwards compatibility with existing config.toml + Backend: LogflareBigQuery, + }, + EdgeRuntime: edgeRuntime{ + Image: Images.EdgeRuntime, }, - External: map[string]provider{}, - SigningKeys: []JWK{{ - KeyType: "EC", - KeyID: "b81269f1-21d8-4f2e-b719-c2240a840d90", - Use: "sig", - KeyOps: []string{"sign", "verify"}, - Algorithm: "ES256", - Extractable: cast.Ptr(true), - Curve: "P-256", - X: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - Y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - PrivateExponent: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", - }}, - }, - Inbucket: inbucket{ - Image: Images.Inbucket, - AdminEmail: "admin@email.com", - SenderName: "Admin", - }, - Studio: studio{ - Image: Images.Studio, - PgmetaImage: Images.Pgmeta, - }, - Analytics: analytics{ - Image: Images.Logflare, - VectorImage: Images.Vector, - ApiKey: "api-key", - // Defaults to bigquery for backwards compatibility with existing config.toml - Backend: LogflareBigQuery, }, - EdgeRuntime: edgeRuntime{ - Image: Images.EdgeRuntime, + Local: local{ + Runtime: DockerRuntime, }, - }} + } for _, apply := range editors { apply(&initial) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 42c28acfd4..c96c00823b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -251,6 +251,27 @@ func TestFileSizeLimitConfigParsing(t *testing.T) { }) } +func TestLocalRuntimeConfig(t *testing.T) { + t.Run("defaults to docker runtime", func(t *testing.T) { + config := NewConfig() + + assert.Equal(t, DockerRuntime, config.Local.Runtime) + }) + + t.Run("parses apple container runtime", func(t *testing.T) { + var testConfig config + + _, err := toml.Decode(` + [local] + runtime = "apple-container" + `, &testConfig) + + if assert.NoError(t, err) { + assert.Equal(t, AppleContainerRuntime, testConfig.Local.Runtime) + } + }) +} + func TestSanitizeProjectI(t *testing.T) { // Preserves valid consecutive characters assert.Equal(t, "abc", sanitizeProjectId("abc")) diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index f4d5a7961e..b21c3e33e4 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -4,6 +4,10 @@ # working directory name when running `supabase init`. project_id = "{{ .ProjectId }}" +[local] +# Container runtime for local development. Supported values are: `docker`, `apple-container`. +runtime = "{{ .Local.Runtime }}" + [api] enabled = true # Port to use for the API URL. From f0718c39d5b7175f9a108721c376d6880cf0c16e Mon Sep 17 00:00:00 2001 From: James Jackson Date: Mon, 16 Mar 2026 12:51:49 -0400 Subject: [PATCH 2/6] feat: support analytics on apple-container runtime Enable the analytics stack when services run on the apple-container runtime. This adds an Apple-specific log ingestion path for Vector by forwarding container logs to host-side JSONL files and configuring Vector to read those files instead of Docker's log source. Docker behavior remains unchanged. --- cmd/apple_log_forwarder.go | 29 +++ cmd/root.go | 3 +- internal/start/start.go | 132 +++++++++----- internal/start/start_test.go | 121 +++++++++++++ internal/start/templates/vector.yaml | 41 +++-- internal/stop/stop.go | 5 + internal/utils/apple_analytics.go | 239 +++++++++++++++++++++++++ internal/utils/apple_analytics_test.go | 76 ++++++++ 8 files changed, 595 insertions(+), 51 deletions(-) create mode 100644 cmd/apple_log_forwarder.go create mode 100644 internal/utils/apple_analytics.go create mode 100644 internal/utils/apple_analytics_test.go diff --git a/cmd/apple_log_forwarder.go b/cmd/apple_log_forwarder.go new file mode 100644 index 0000000000..c35423a353 --- /dev/null +++ b/cmd/apple_log_forwarder.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/supabase/cli/internal/utils" +) + +var ( + appleLogForwarderContainer string + appleLogForwarderOutput string + + appleLogForwarderCmd = &cobra.Command{ + Use: "apple-log-forwarder", + Short: "Internal Apple analytics log forwarder", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return utils.RunAppleAnalyticsLogForwarder(cmd.Context(), appleLogForwarderContainer, appleLogForwarderOutput) + }, + } +) + +func init() { + flags := appleLogForwarderCmd.Flags() + flags.StringVar(&appleLogForwarderContainer, "container", "", "container id to follow") + flags.StringVar(&appleLogForwarderOutput, "output", "", "output path for JSONL logs") + cobra.CheckErr(appleLogForwarderCmd.MarkFlagRequired("container")) + cobra.CheckErr(appleLogForwarderCmd.MarkFlagRequired("output")) + rootCmd.AddCommand(appleLogForwarderCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 1764b1d8a4..4428b3a978 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "strings" + "syscall" "time" "github.com/getsentry/sentry-go" @@ -94,7 +95,7 @@ var ( } cmd.SilenceUsage = true // Load profile before changing workdir - ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt) + ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) fsys := afero.NewOsFs() if err := utils.LoadProfile(ctx, fsys); err != nil { return err diff --git a/internal/start/start.go b/internal/start/start.go index d55446d989..9c51a00370 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -135,6 +135,7 @@ type vectorConfig struct { ApiKey string VectorId string LogflareId string + LogflareHost string KongId string GotrueId string RestId string @@ -142,6 +143,9 @@ type vectorConfig struct { StorageId string EdgeRuntimeId string DbId string + SourceName string + SourceType string + SourceInclude []string } var ( @@ -169,6 +173,18 @@ var ( var serviceTimeout = 30 * time.Second +var ( + startAppleAnalyticsForwarders = utils.StartAppleAnalyticsForwarders + stopAppleAnalyticsForwarders = utils.StopAppleAnalyticsForwarders +) + +const ( + vectorSourceDockerLogs = "docker_logs" + vectorSourceFile = "file" + appleVectorLogDir = "/var/log/supabase" + appleVectorLogGlob = appleVectorLogDir + "/*.jsonl" +) + var resolveContainerIP = utils.GetContainerIP var listProjectContainers = utils.ListProjectContainers var removeProjectContainer = utils.RemoveContainer @@ -287,6 +303,35 @@ func buildKongConfig(ctx context.Context, deps KongDependencies) (kongConfig, er }, nil } +func buildVectorConfig(ctx context.Context) (vectorConfig, error) { + cfg := vectorConfig{ + ApiKey: utils.Config.Analytics.ApiKey, + VectorId: utils.VectorId, + LogflareId: utils.LogflareId, + LogflareHost: utils.LogflareId, + KongId: utils.KongId, + GotrueId: utils.GotrueId, + RestId: utils.RestId, + RealtimeId: utils.RealtimeId, + StorageId: utils.StorageId, + EdgeRuntimeId: utils.EdgeRuntimeId, + DbId: utils.DbId, + SourceName: "docker_host", + SourceType: vectorSourceDockerLogs, + } + if utils.UsesAppleContainerRuntime() { + logflareHost, err := runtimeContainerHost(ctx, utils.LogflareId, true) + if err != nil { + return vectorConfig{}, err + } + cfg.LogflareHost = logflareHost + cfg.SourceName = "apple_logs" + cfg.SourceType = vectorSourceFile + cfg.SourceInclude = []string{appleVectorLogGlob} + } + return cfg, nil +} + func startKong(ctx context.Context, deps KongDependencies) error { var kongConfigBuf bytes.Buffer kongConfig, err := buildKongConfig(ctx, deps) @@ -478,17 +523,13 @@ func run(ctx context.Context, fsys afero.Fs, excludedContainers []string, dbConf for _, name := range excludedContainers { excluded[name] = true } - if utils.UsesAppleContainerRuntime() { - if !excluded[utils.ShortContainerImageName(utils.Config.Analytics.Image)] || !excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] { - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "apple-container runtime does not support analytics yet; skipping logflare and vector.") - excluded[utils.ShortContainerImageName(utils.Config.Analytics.Image)] = true - excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] = true - } - } notExcluded := func(sc types.ServiceConfig) bool { val, ok := excluded[sc.Name] return !val || !ok } + if utils.UsesAppleContainerRuntime() && !excluded[utils.ShortContainerImageName(utils.Config.Analytics.VectorImage)] { + _ = stopAppleAnalyticsForwarders(afero.NewOsFs()) + } jwks, err := utils.Config.Auth.ResolveJWKS(ctx) if err != nil { @@ -626,49 +667,54 @@ EOF // Start vector if isVectorEnabled { + cfg, err := buildVectorConfig(ctx) + if err != nil { + return err + } var vectorConfigBuf bytes.Buffer - if err := vectorConfigTemplate.Option("missingkey=error").Execute(&vectorConfigBuf, vectorConfig{ - ApiKey: utils.Config.Analytics.ApiKey, - VectorId: utils.VectorId, - LogflareId: utils.LogflareId, - KongId: utils.KongId, - GotrueId: utils.GotrueId, - RestId: utils.RestId, - RealtimeId: utils.RealtimeId, - StorageId: utils.StorageId, - EdgeRuntimeId: utils.EdgeRuntimeId, - DbId: utils.DbId, - }); err != nil { + if err := vectorConfigTemplate.Option("missingkey=error").Execute(&vectorConfigBuf, cfg); err != nil { return errors.Errorf("failed to exec template: %w", err) } var binds, env, securityOpts []string + if utils.UsesAppleContainerRuntime() { + hostLogDir, err := utils.AppleAnalyticsLogsDirPath() + if err != nil { + return errors.Errorf("failed to resolve apple analytics log dir: %w", err) + } + if err := os.MkdirAll(hostLogDir, 0755); err != nil { + return errors.Errorf("failed to create apple analytics log dir: %w", err) + } + binds = append(binds, hostLogDir+":"+appleVectorLogDir+":rw") + } // Special case for GitLab pipeline parsed, err := client.ParseHostURL(utils.Docker.DaemonHost()) if err != nil { return errors.Errorf("failed to parse docker host: %w", err) } // Ref: https://vector.dev/docs/reference/configuration/sources/docker_logs/#docker_host - dindHost := &url.URL{Scheme: "http", Host: net.JoinHostPort(utils.DinDHost, "2375")} - switch parsed.Scheme { - case "tcp": - if _, port, err := net.SplitHostPort(parsed.Host); err == nil { - dindHost.Host = net.JoinHostPort(utils.DinDHost, port) - } - env = append(env, "DOCKER_HOST="+dindHost.String()) - case "npipe": - const dockerDaemonNeededErr = "Analytics on Windows requires Docker daemon exposed on tcp://localhost:2375.\nSee https://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows#running-supabase-locally for more details." - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), dockerDaemonNeededErr) - env = append(env, "DOCKER_HOST="+dindHost.String()) - case "unix": - if dindHost, err = client.ParseHostURL(client.DefaultDockerHost); err != nil { - return errors.Errorf("failed to parse default host: %w", err) - } else if strings.HasSuffix(parsed.Host, "/.docker/run/docker.sock") { - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "analytics requires mounting default docker socket:", dindHost.Host) - binds = append(binds, fmt.Sprintf("%[1]s:%[1]s:ro", dindHost.Host)) - } else { - // Podman and OrbStack can mount root-less socket without issue - binds = append(binds, fmt.Sprintf("%s:%s:ro", parsed.Host, dindHost.Host)) - securityOpts = append(securityOpts, "label:disable") + if !utils.UsesAppleContainerRuntime() { + dindHost := &url.URL{Scheme: "http", Host: net.JoinHostPort(utils.DinDHost, "2375")} + switch parsed.Scheme { + case "tcp": + if _, port, err := net.SplitHostPort(parsed.Host); err == nil { + dindHost.Host = net.JoinHostPort(utils.DinDHost, port) + } + env = append(env, "DOCKER_HOST="+dindHost.String()) + case "npipe": + const dockerDaemonNeededErr = "Analytics on Windows requires Docker daemon exposed on tcp://localhost:2375.\nSee https://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows#running-supabase-locally for more details." + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), dockerDaemonNeededErr) + env = append(env, "DOCKER_HOST="+dindHost.String()) + case "unix": + if dindHost, err = client.ParseHostURL(client.DefaultDockerHost); err != nil { + return errors.Errorf("failed to parse default host: %w", err) + } else if strings.HasSuffix(parsed.Host, "/.docker/run/docker.sock") { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "analytics requires mounting default docker socket:", dindHost.Host) + binds = append(binds, fmt.Sprintf("%[1]s:%[1]s:ro", dindHost.Host)) + } else { + // Podman and OrbStack can mount root-less socket without issue + binds = append(binds, fmt.Sprintf("%s:%s:ro", parsed.Host, dindHost.Host)) + securityOpts = append(securityOpts, "label:disable") + } } } if _, err := utils.DockerStart( @@ -1399,6 +1445,12 @@ EOF started = append(started, utils.KongId) } + if utils.UsesAppleContainerRuntime() && isVectorEnabled { + if err := startAppleAnalyticsForwarders(utils.AppleAnalyticsSourceContainers()); err != nil { + return err + } + } + // Start Studio. if isStudioEnabled { binds, _, err := serve.PopulatePerFunctionConfigs(workdir, "", nil, fsys) diff --git a/internal/start/start_test.go b/internal/start/start_test.go index 87c45b6329..47e92b6733 100644 --- a/internal/start/start_test.go +++ b/internal/start/start_test.go @@ -497,6 +497,127 @@ func TestBuildStudioEnv(t *testing.T) { assert.True(t, foundFunctionsDir) } +func TestBuildVectorConfig(t *testing.T) { + originalRuntime := utils.Config.Local.Runtime + originalResolver := resolveContainerIP + originalIDs := struct { + vector, logflare, kong, gotrue, rest, realtime, storage, edge, db string + }{ + vector: utils.VectorId, + logflare: utils.LogflareId, + kong: utils.KongId, + gotrue: utils.GotrueId, + rest: utils.RestId, + realtime: utils.RealtimeId, + storage: utils.StorageId, + edge: utils.EdgeRuntimeId, + db: utils.DbId, + } + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + resolveContainerIP = originalResolver + utils.VectorId = originalIDs.vector + utils.LogflareId = originalIDs.logflare + utils.KongId = originalIDs.kong + utils.GotrueId = originalIDs.gotrue + utils.RestId = originalIDs.rest + utils.RealtimeId = originalIDs.realtime + utils.StorageId = originalIDs.storage + utils.EdgeRuntimeId = originalIDs.edge + utils.DbId = originalIDs.db + }) + utils.VectorId = "test-vector" + utils.LogflareId = "test-logflare" + utils.KongId = "test-kong" + utils.GotrueId = "test-gotrue" + utils.RestId = "test-rest" + utils.RealtimeId = "test-realtime" + utils.StorageId = "test-storage" + utils.EdgeRuntimeId = "test-edge" + utils.DbId = "test-db" + + t.Run("uses docker source by default", func(t *testing.T) { + utils.Config.Local.Runtime = config.DockerRuntime + + cfg, err := buildVectorConfig(context.Background()) + + require.NoError(t, err) + assert.Equal(t, vectorSourceDockerLogs, cfg.SourceType) + assert.Equal(t, "docker_host", cfg.SourceName) + assert.Empty(t, cfg.SourceInclude) + assert.Equal(t, "test-logflare", cfg.LogflareHost) + }) + + t.Run("uses file source and resolved logflare host on apple", func(t *testing.T) { + utils.Config.Local.Runtime = config.AppleContainerRuntime + resolveContainerIP = func(_ context.Context, containerId, _ string) (string, error) { + assert.Equal(t, "test-logflare", containerId) + return "192.168.0.40", nil + } + + cfg, err := buildVectorConfig(context.Background()) + + require.NoError(t, err) + assert.Equal(t, vectorSourceFile, cfg.SourceType) + assert.Equal(t, "apple_logs", cfg.SourceName) + assert.Equal(t, []string{appleVectorLogGlob}, cfg.SourceInclude) + assert.Equal(t, "192.168.0.40", cfg.LogflareHost) + }) +} + +func TestRenderVectorConfig(t *testing.T) { + t.Run("renders docker log source", func(t *testing.T) { + var buf bytes.Buffer + err := vectorConfigTemplate.Option("missingkey=error").Execute(&buf, vectorConfig{ + ApiKey: "api-key", + VectorId: "test-vector", + LogflareHost: "test-logflare", + KongId: "test-kong", + GotrueId: "test-gotrue", + RestId: "test-rest", + RealtimeId: "test-realtime", + StorageId: "test-storage", + EdgeRuntimeId: "test-edge", + DbId: "test-db", + SourceName: "docker_host", + SourceType: vectorSourceDockerLogs, + }) + require.NoError(t, err) + rendered := buf.String() + assert.Contains(t, rendered, "docker_host:") + assert.Contains(t, rendered, "type: docker_logs") + assert.Contains(t, rendered, "exclude_containers:") + assert.Contains(t, rendered, "http://test-logflare:4000/api/logs?source_name=gotrue.logs.prod") + }) + + t.Run("renders apple file source", func(t *testing.T) { + var buf bytes.Buffer + err := vectorConfigTemplate.Option("missingkey=error").Execute(&buf, vectorConfig{ + ApiKey: "api-key", + VectorId: "test-vector", + LogflareHost: "192.168.0.40", + KongId: "test-kong", + GotrueId: "test-gotrue", + RestId: "test-rest", + RealtimeId: "test-realtime", + StorageId: "test-storage", + EdgeRuntimeId: "test-edge", + DbId: "test-db", + SourceName: "apple_logs", + SourceType: vectorSourceFile, + SourceInclude: []string{appleVectorLogGlob}, + }) + require.NoError(t, err) + rendered := buf.String() + assert.Contains(t, rendered, "apple_logs:") + assert.Contains(t, rendered, "type: file") + assert.Contains(t, rendered, appleVectorLogGlob) + assert.Contains(t, rendered, "apple_json_logs:") + assert.Contains(t, rendered, `. = parse_json!(string!(.message))`) + assert.Contains(t, rendered, "http://192.168.0.40:4000/api/logs?source_name=gotrue.logs.prod") + }) +} + func TestFormatMapForEnvConfig(t *testing.T) { t.Run("It produces the correct format and removes the trailing comma", func(t *testing.T) { testcases := []struct { diff --git a/internal/start/templates/vector.yaml b/internal/start/templates/vector.yaml index 1c7609984e..da0a964a56 100644 --- a/internal/start/templates/vector.yaml +++ b/internal/start/templates/vector.yaml @@ -3,16 +3,37 @@ api: address: 0.0.0.0:9001 sources: - docker_host: - type: docker_logs + {{ .SourceName }}: + type: {{ .SourceType }} + {{- if eq .SourceType "docker_logs" }} exclude_containers: - "{{ .VectorId }}" + {{- else if eq .SourceType "file" }} + include: + {{- range .SourceInclude }} + - "{{ . }}" + {{- end }} + read_from: beginning + ignore_checkpoints: true + {{- end }} transforms: + {{- if eq .SourceType "file" }} + apple_json_logs: + type: remap + inputs: + - {{ .SourceName }} + source: |- + . = parse_json!(string!(.message)) + {{- end }} project_logs: type: remap inputs: - - docker_host + {{- if eq .SourceType "file" }} + - apple_json_logs + {{- else }} + - {{ .SourceName }} + {{- end }} source: |- .project = "default" .event_message = del(.message) @@ -174,7 +195,7 @@ sinks: retry_max_duration_secs: 10 headers: x-api-key: "{{ .ApiKey }}" - uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=gotrue.logs.prod" + uri: "http://{{ .LogflareHost }}:4000/api/logs?source_name=gotrue.logs.prod" logflare_realtime: type: "http" inputs: @@ -186,7 +207,7 @@ sinks: retry_max_duration_secs: 10 headers: x-api-key: "{{ .ApiKey }}" - uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=realtime.logs.prod" + uri: "http://{{ .LogflareHost }}:4000/api/logs?source_name=realtime.logs.prod" logflare_rest: type: "http" inputs: @@ -198,7 +219,7 @@ sinks: retry_max_duration_secs: 10 headers: x-api-key: "{{ .ApiKey }}" - uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=postgREST.logs.prod" + uri: "http://{{ .LogflareHost }}:4000/api/logs?source_name=postgREST.logs.prod" logflare_db: type: "http" inputs: @@ -210,7 +231,7 @@ sinks: retry_max_duration_secs: 10 headers: x-api-key: "{{ .ApiKey }}" - uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=postgres.logs" + uri: "http://{{ .LogflareHost }}:4000/api/logs?source_name=postgres.logs" logflare_functions: type: "http" inputs: @@ -222,7 +243,7 @@ sinks: retry_max_duration_secs: 10 headers: x-api-key: "{{ .ApiKey }}" - uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=deno-relay-logs" + uri: "http://{{ .LogflareHost }}:4000/api/logs?source_name=deno-relay-logs" logflare_storage: type: "http" inputs: @@ -234,7 +255,7 @@ sinks: retry_max_duration_secs: 10 headers: x-api-key: "{{ .ApiKey }}" - uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=storage.logs.prod.2" + uri: "http://{{ .LogflareHost }}:4000/api/logs?source_name=storage.logs.prod.2" logflare_kong: type: "http" inputs: @@ -247,4 +268,4 @@ sinks: retry_max_duration_secs: 10 headers: x-api-key: "{{ .ApiKey }}" - uri: "http://{{ .LogflareId }}:4000/api/logs?source_name=cloudflare.logs.prod" + uri: "http://{{ .LogflareHost }}:4000/api/logs?source_name=cloudflare.logs.prod" diff --git a/internal/stop/stop.go b/internal/stop/stop.go index ec9e96070e..1c07c25dda 100644 --- a/internal/stop/stop.go +++ b/internal/stop/stop.go @@ -22,6 +22,11 @@ func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afer } searchProjectIdFilter = utils.Config.ProjectId } + if utils.UsesAppleContainerRuntime() { + if err := utils.StopAppleAnalyticsForwarders(fsys); err != nil { + return err + } + } // Stop all services if err := utils.RunProgram(ctx, func(p utils.Program, ctx context.Context) error { diff --git a/internal/utils/apple_analytics.go b/internal/utils/apple_analytics.go new file mode 100644 index 0000000000..37b6aea83c --- /dev/null +++ b/internal/utils/apple_analytics.go @@ -0,0 +1,239 @@ +package utils + +import ( + "bufio" + "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-errors/errors" + "github.com/spf13/afero" +) + +const ( + appleAnalyticsStateDirName = "apple-analytics" + appleAnalyticsLogsDirName = "logs" + appleAnalyticsPidsDirName = "pids" +) + +var ( + resolveAppleAnalyticsStateDir = func() (string, error) { + return filepath.Abs(filepath.Join(TempDir, appleAnalyticsStateDirName)) + } + startAppleAnalyticsForwarderProcess = func(containerID, outputPath string) (int, error) { + executable, err := os.Executable() + if err != nil { + return 0, errors.Errorf("failed to resolve executable: %w", err) + } + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return 0, errors.Errorf("failed to open null device: %w", err) + } + defer devNull.Close() + cmd := exec.Command(executable, "apple-log-forwarder", "--container", containerID, "--output", outputPath) + cmd.Stdout = devNull + cmd.Stderr = devNull + if err := cmd.Start(); err != nil { + return 0, errors.Errorf("failed to start apple analytics forwarder: %w", err) + } + return cmd.Process.Pid, nil + } + interruptAppleAnalyticsForwarderProcess = func(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + return process.Signal(os.Interrupt) + } +) + +type appleAnalyticsLogEvent struct { + Timestamp string `json:"timestamp"` + Message string `json:"message"` + ContainerName string `json:"container_name"` + Stream string `json:"stream"` +} + +type appleAnalyticsLogWriter struct { + mu sync.Mutex + w io.Writer +} + +func AppleAnalyticsSourceContainers() []string { + return []string{ + DbId, + GotrueId, + RestId, + RealtimeId, + StorageId, + EdgeRuntimeId, + KongId, + } +} + +func AppleAnalyticsLogsDirPath() (string, error) { + stateDir, err := resolveAppleAnalyticsStateDir() + if err != nil { + return "", err + } + return filepath.Join(stateDir, appleAnalyticsLogsDirName), nil +} + +func StartAppleAnalyticsForwarders(containerIDs []string) error { + if len(containerIDs) == 0 { + return nil + } + stateDir, err := resolveAppleAnalyticsStateDir() + if err != nil { + return err + } + logDir := filepath.Join(stateDir, appleAnalyticsLogsDirName) + pidDir := filepath.Join(stateDir, appleAnalyticsPidsDirName) + if err := os.RemoveAll(stateDir); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Errorf("failed to reset apple analytics state: %w", err) + } + if err := os.MkdirAll(logDir, 0755); err != nil { + return errors.Errorf("failed to create apple analytics log dir: %w", err) + } + if err := os.MkdirAll(pidDir, 0755); err != nil { + return errors.Errorf("failed to create apple analytics pid dir: %w", err) + } + for _, containerID := range containerIDs { + outputPath := filepath.Join(logDir, containerID+".jsonl") + pid, err := startAppleAnalyticsForwarderProcess(containerID, outputPath) + if err != nil { + return err + } + pidPath := filepath.Join(pidDir, containerID+".pid") + if err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0644); err != nil { + return errors.Errorf("failed to write apple analytics pid: %w", err) + } + } + return nil +} + +func StopAppleAnalyticsForwarders(fsys afero.Fs) error { + stateDir, err := resolveAppleAnalyticsStateDir() + if err != nil { + return err + } + pidDir := filepath.Join(stateDir, appleAnalyticsPidsDirName) + entries, err := afero.ReadDir(fsys, pidDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Errorf("failed to read apple analytics pid dir: %w", err) + } + var allErrors []error + for _, entry := range entries { + pidPath := filepath.Join(pidDir, entry.Name()) + pidBytes, err := afero.ReadFile(fsys, pidPath) + if err != nil { + allErrors = append(allErrors, errors.Errorf("failed to read apple analytics pid: %w", err)) + continue + } + pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))) + if err != nil { + allErrors = append(allErrors, errors.Errorf("failed to parse apple analytics pid: %w", err)) + continue + } + if err := interruptAppleAnalyticsForwarderProcess(pid); err != nil && !errors.Is(err, os.ErrProcessDone) { + allErrors = append(allErrors, errors.Errorf("failed to stop apple analytics forwarder: %w", err)) + } + } + if err := fsys.RemoveAll(stateDir); err != nil && !errors.Is(err, os.ErrNotExist) { + allErrors = append(allErrors, errors.Errorf("failed to remove apple analytics state: %w", err)) + } + return errors.Join(allErrors...) +} + +func RunAppleAnalyticsLogForwarder(ctx context.Context, containerID, outputPath string) error { + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return errors.Errorf("failed to create apple analytics output dir: %w", err) + } + output, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return errors.Errorf("failed to open apple analytics output: %w", err) + } + defer output.Close() + + cmd := execContainerCommand(ctx, "container", "logs", "--follow", containerID) + stdout, err := cmd.StdoutPipe() + if err != nil { + return errors.Errorf("failed to capture apple analytics stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return errors.Errorf("failed to capture apple analytics stderr: %w", err) + } + if err := cmd.Start(); err != nil { + return wrapAppleContainerError(err, nil) + } + + writer := &appleAnalyticsLogWriter{w: output} + streamErrCh := make(chan error, 2) + go func() { + streamErrCh <- streamAppleAnalyticsLogs(stdout, writer, containerID, "stdout") + }() + go func() { + streamErrCh <- streamAppleAnalyticsLogs(stderr, writer, containerID, "stderr") + }() + firstErr := <-streamErrCh + secondErr := <-streamErrCh + // Wait() closes the pipes it created, so let both readers drain first. + waitErr := cmd.Wait() + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + if firstErr != nil { + return firstErr + } + if secondErr != nil { + return secondErr + } + if waitErr != nil { + return wrapAppleContainerError(waitErr, nil) + } + return nil +} + +func streamAppleAnalyticsLogs(r io.Reader, writer *appleAnalyticsLogWriter, containerID, stream string) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if len(strings.TrimSpace(line)) == 0 { + continue + } + if err := writer.writeEvent(appleAnalyticsLogEvent{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Message: line, + ContainerName: containerID, + Stream: stream, + }); err != nil { + return err + } + } + if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) { + return errors.Errorf("failed to stream apple analytics logs: %w", err) + } + return nil +} + +func (w *appleAnalyticsLogWriter) writeEvent(event appleAnalyticsLogEvent) error { + w.mu.Lock() + defer w.mu.Unlock() + data, err := json.Marshal(event) + if err != nil { + return errors.Errorf("failed to encode apple analytics log: %w", err) + } + if _, err := w.w.Write(append(data, '\n')); err != nil { + return errors.Errorf("failed to write apple analytics log: %w", err) + } + return nil +} diff --git a/internal/utils/apple_analytics_test.go b/internal/utils/apple_analytics_test.go new file mode 100644 index 0000000000..971104c0c4 --- /dev/null +++ b/internal/utils/apple_analytics_test.go @@ -0,0 +1,76 @@ +package utils + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunAppleAnalyticsLogForwarder(t *testing.T) { + originalExec := execContainerCommand + t.Cleanup(func() { + execContainerCommand = originalExec + }) + execContainerCommand = func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("sh", "-c", "printf 'hello\\n'; printf 'warn\\n' 1>&2") + } + + outputPath := filepath.Join(t.TempDir(), "forwarder.jsonl") + err := RunAppleAnalyticsLogForwarder(context.Background(), "supabase-rest-demo", outputPath) + + require.NoError(t, err) + data, err := os.ReadFile(outputPath) + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + require.Len(t, lines, 2) + joined := strings.Join(lines, "\n") + assert.Contains(t, joined, `"container_name":"supabase-rest-demo"`) + assert.Contains(t, joined, `"stream":"stdout"`) + assert.Contains(t, joined, `"message":"hello"`) + assert.Contains(t, joined, `"stream":"stderr"`) + assert.Contains(t, joined, `"message":"warn"`) +} + +func TestAppleAnalyticsForwarderLifecycle(t *testing.T) { + originalStateDir := resolveAppleAnalyticsStateDir + originalStarter := startAppleAnalyticsForwarderProcess + originalInterrupt := interruptAppleAnalyticsForwarderProcess + tempDir := t.TempDir() + var started []string + var stopped []int + t.Cleanup(func() { + resolveAppleAnalyticsStateDir = originalStateDir + startAppleAnalyticsForwarderProcess = originalStarter + interruptAppleAnalyticsForwarderProcess = originalInterrupt + }) + resolveAppleAnalyticsStateDir = func() (string, error) { + return tempDir, nil + } + startAppleAnalyticsForwarderProcess = func(containerID, outputPath string) (int, error) { + started = append(started, containerID+"="+outputPath) + return len(started) + 100, nil + } + interruptAppleAnalyticsForwarderProcess = func(pid int) error { + stopped = append(stopped, pid) + return nil + } + + err := StartAppleAnalyticsForwarders([]string{"db", "rest"}) + require.NoError(t, err) + assert.Len(t, started, 2) + assert.FileExists(t, filepath.Join(tempDir, appleAnalyticsPidsDirName, "db.pid")) + assert.FileExists(t, filepath.Join(tempDir, appleAnalyticsPidsDirName, "rest.pid")) + + err = StopAppleAnalyticsForwarders(afero.NewOsFs()) + require.NoError(t, err) + assert.Equal(t, []int{101, 102}, stopped) + _, err = os.Stat(tempDir) + assert.ErrorIs(t, err, os.ErrNotExist) +} From 0f6b42d1bbe6c521a0ca7ef8c2a07b71ed1c8b57 Mon Sep 17 00:00:00 2001 From: James Jackson Date: Tue, 17 Mar 2026 20:24:00 -0400 Subject: [PATCH 3/6] docs: add reviewer-facing comments for apple-container runtime Add explanatory comments addressing likely review questions: - Why strategy-via-conditionals instead of an interface - Kong startup order change (IP resolution needs running containers) - Analytics log forwarding architecture (no Docker log driver) - PGDATA override for Apple container volume mounts - KongId added to listServicesToRestart (behavioral change) - Host header in kong.yml for Realtime tenant routing - Stale container reconciliation rationale - Apple volume delete lacks force flag Also add saveResetTestState helper to reduce verbose save/restore boilerplate in reset_test.go. --- internal/db/reset/reset.go | 7 ++ internal/db/reset/reset_test.go | 117 +++++++++++++++++++----------- internal/db/start/start.go | 6 ++ internal/start/start.go | 25 ++++++- internal/start/templates/kong.yml | 3 + internal/utils/apple_container.go | 1 + internal/utils/runtime.go | 11 +++ 7 files changed, 126 insertions(+), 44 deletions(-) diff --git a/internal/db/reset/reset.go b/internal/db/reset/reset.go index 1d8c99cc8d..1e8fc001e1 100644 --- a/internal/db/reset/reset.go +++ b/internal/db/reset/reset.go @@ -110,6 +110,9 @@ func Run(ctx context.Context, version string, last uint, config pgconn.Config, f return nil } +// shouldRefreshAPIAfterReset returns true when Kong must be recreated after a +// database reset. Apple containers assign dynamic IPs, so Kong's cached +// upstream addresses become stale when the database container is replaced. func shouldRefreshAPIAfterReset() bool { return utils.UsesAppleContainerRuntime() && utils.Config.Api.Enabled } @@ -303,6 +306,10 @@ func restartServices(ctx context.Context) error { return errors.Join(result...) } +// listServicesToRestart returns containers that need restarting after a +// database reset. Kong is included because it caches upstream addresses that +// may change when the database container is recreated (especially on Apple +// containers which use dynamic IPs). func listServicesToRestart() []string { return []string{utils.StorageId, utils.GotrueId, utils.RealtimeId, utils.PoolerId, utils.KongId} } diff --git a/internal/db/reset/reset_test.go b/internal/db/reset/reset_test.go index e95db397ee..ef6e6b7684 100644 --- a/internal/db/reset/reset_test.go +++ b/internal/db/reset/reset_test.go @@ -24,10 +24,83 @@ import ( "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/pgtest" "github.com/supabase/cli/pkg/storage" ) +// saveResetTestState captures and restores all package-level vars that the +// apple-container reset tests override, reducing repetitive save/restore +// boilerplate in individual test cases. +func saveResetTestState(t *testing.T) { + t.Helper() + orig := struct { + runtime string + apiEnabled bool + assertRunning func() error + removeContainer func(context.Context, string, bool, bool) error + removeVolume func(context.Context, string, bool) error + startContainer func(context.Context, container.Config, container.HostConfig, network.NetworkingConfig, string) (string, error) + inspectContainer func(context.Context, string) (utils.ContainerInfo, error) + restartContainer func(context.Context, string) error + waitForHealthy func(context.Context, time.Duration, ...string) error + waitForLocalDB func(context.Context, time.Duration, ...func(*pgx.ConnConfig)) error + waitForLocalAPI func(context.Context, time.Duration) error + setupLocalDB func(context.Context, string, afero.Fs, io.Writer, ...func(*pgx.ConnConfig)) error + restartKongFn func(context.Context, start.KongDependencies) error + runBucketSeedFn func(context.Context, string, bool, afero.Fs) error + seedBucketsFn func(context.Context, afero.Fs) error + dbId, storageId string + gotrueId, realtimeId string + poolerId, kongId string + }{ + runtime: string(utils.Config.Local.Runtime), + apiEnabled: utils.Config.Api.Enabled, + assertRunning: assertSupabaseDbIsRunning, + removeContainer: removeContainer, + removeVolume: removeVolume, + startContainer: startContainer, + inspectContainer: inspectContainer, + restartContainer: restartContainer, + waitForHealthy: waitForHealthyService, + waitForLocalDB: waitForLocalDatabase, + waitForLocalAPI: waitForLocalAPI, + setupLocalDB: setupLocalDatabase, + restartKongFn: restartKong, + runBucketSeedFn: runBucketSeed, + seedBucketsFn: seedBuckets, + dbId: utils.DbId, + storageId: utils.StorageId, + gotrueId: utils.GotrueId, + realtimeId: utils.RealtimeId, + poolerId: utils.PoolerId, + kongId: utils.KongId, + } + t.Cleanup(func() { + utils.Config.Local.Runtime = config.LocalRuntime(orig.runtime) + utils.Config.Api.Enabled = orig.apiEnabled + assertSupabaseDbIsRunning = orig.assertRunning + removeContainer = orig.removeContainer + removeVolume = orig.removeVolume + startContainer = orig.startContainer + inspectContainer = orig.inspectContainer + restartContainer = orig.restartContainer + waitForHealthyService = orig.waitForHealthy + waitForLocalDatabase = orig.waitForLocalDB + waitForLocalAPI = orig.waitForLocalAPI + setupLocalDatabase = orig.setupLocalDB + restartKong = orig.restartKongFn + runBucketSeed = orig.runBucketSeedFn + seedBuckets = orig.seedBucketsFn + utils.DbId = orig.dbId + utils.StorageId = orig.storageId + utils.GotrueId = orig.gotrueId + utils.RealtimeId = orig.realtimeId + utils.PoolerId = orig.poolerId + utils.KongId = orig.kongId + }) +} + func TestResetCommand(t *testing.T) { utils.Config.Hostname = "127.0.0.1" utils.Config.Db.Port = 5432 @@ -170,49 +243,7 @@ func TestResetCommand(t *testing.T) { }) t.Run("uses runtime helpers on apple container runtime", func(t *testing.T) { - originalRuntime := utils.Config.Local.Runtime - originalAPIEnabled := utils.Config.Api.Enabled - originalAssertRunning := assertSupabaseDbIsRunning - originalRemoveContainer := removeContainer - originalRemoveVolume := removeVolume - originalStartContainer := startContainer - originalInspectContainer := inspectContainer - originalRestartContainer := restartContainer - originalWaitForHealthyService := waitForHealthyService - originalWaitForLocalDatabase := waitForLocalDatabase - originalWaitForLocalAPI := waitForLocalAPI - originalSetupLocalDatabase := setupLocalDatabase - originalRestartKong := restartKong - originalRunBucketSeed := runBucketSeed - originalDbID := utils.DbId - originalStorageID := utils.StorageId - originalGotrueID := utils.GotrueId - originalRealtimeID := utils.RealtimeId - originalPoolerID := utils.PoolerId - originalKongID := utils.KongId - - t.Cleanup(func() { - utils.Config.Local.Runtime = originalRuntime - utils.Config.Api.Enabled = originalAPIEnabled - assertSupabaseDbIsRunning = originalAssertRunning - removeContainer = originalRemoveContainer - removeVolume = originalRemoveVolume - startContainer = originalStartContainer - inspectContainer = originalInspectContainer - restartContainer = originalRestartContainer - waitForHealthyService = originalWaitForHealthyService - waitForLocalDatabase = originalWaitForLocalDatabase - waitForLocalAPI = originalWaitForLocalAPI - setupLocalDatabase = originalSetupLocalDatabase - restartKong = originalRestartKong - runBucketSeed = originalRunBucketSeed - utils.DbId = originalDbID - utils.StorageId = originalStorageID - utils.GotrueId = originalGotrueID - utils.RealtimeId = originalRealtimeID - utils.PoolerId = originalPoolerID - utils.KongId = originalKongID - }) + saveResetTestState(t) utils.Config.Local.Runtime = "apple-container" utils.Config.Db.MajorVersion = 15 diff --git a/internal/db/start/start.go b/internal/db/start/start.go index 9c8097605f..6f1f2414d0 100644 --- a/internal/db/start/start.go +++ b/internal/db/start/start.go @@ -41,6 +41,9 @@ var ( resolveContainerIP = utils.GetContainerIP ) +// runtimePostgresConfig returns the postgresql.conf snippet, adding a custom +// data_directory on Apple containers to match the PGDATA env var override +// (see NewContainerConfig). func runtimePostgresConfig() string { settings := utils.Config.Db.Settings.ToPostgresConfig() if utils.UsesAppleContainerRuntime() { @@ -87,6 +90,9 @@ func NewContainerConfig(args ...string) container.Config { } else if i := strings.IndexByte(utils.Config.Db.Image, ':'); config.VersionCompare(utils.Config.Db.Image[i+1:], "15.8.1.005") < 0 { env = append(env, "POSTGRES_INITDB_ARGS=--lc-collate=C.UTF-8") } + // Apple containers mount volumes at the top-level target directory, which + // conflicts with the default PGDATA location. Using a subdirectory avoids + // the "initdb: directory is not empty" error on first start. if utils.UsesAppleContainerRuntime() { env = append(env, "PGDATA=/var/lib/postgresql/data/pgdata") } diff --git a/internal/start/start.go b/internal/start/start.go index 9c51a00370..234b404cc6 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -178,6 +178,18 @@ var ( stopAppleAnalyticsForwarders = utils.StopAppleAnalyticsForwarders ) +// Analytics log forwarding for Apple containers +// +// Apple containers do not expose a Docker-compatible log driver, so the +// Vector `docker_logs` source cannot be used. Instead we: +// 1. Spawn a per-container "forwarder" process (`apple-log-forwarder` +// hidden CLI command) that tails `container logs --follow` and writes +// JSONL to a host directory. +// 2. Mount that directory into the Vector container. +// 3. Configure Vector with a `file` source that reads the JSONL files. +// +// The forwarder PIDs are tracked in a temp directory so `supabase stop` +// can clean them up. const ( vectorSourceDockerLogs = "docker_logs" vectorSourceFile = "file" @@ -205,6 +217,10 @@ func isPermanentError(err error) bool { return true } +// reconcileStaleProjectContainers removes stopped containers left over from a +// previous run. This prevents name collisions when starting new containers, +// which is especially important on Apple containers where stopped containers +// are not automatically cleaned up. func reconcileStaleProjectContainers(ctx context.Context, projectId string) error { containers, err := listProjectContainers(ctx, projectId, true) if err != nil { @@ -221,6 +237,10 @@ func reconcileStaleProjectContainers(ctx context.Context, projectId string) erro return nil } +// runtimeContainerHost returns the hostname that other containers should use +// to reach the given container. Docker networks provide built-in DNS so the +// container name works as a hostname. Apple containers do not have DNS within +// their networks, so we must resolve the container's IP address instead. func runtimeContainerHost(ctx context.Context, containerId string, resolve bool) (string, error) { if !utils.UsesAppleContainerRuntime() || !resolve { return containerId, nil @@ -1427,7 +1447,10 @@ EOF started = append(started, utils.PoolerId) } - // Start Kong after its Apple-runtime upstreams exist. + // Start Kong after its upstream services are running. Apple containers + // require IP resolution (no built-in DNS), so upstream containers must be + // alive before we can build Kong's declarative config. This ordering is + // harmless for Docker where DNS aliases resolve regardless of start order. if isKongEnabled { if err := startKong(ctx, KongDependencies{ Gotrue: isAuthEnabled, diff --git a/internal/start/templates/kong.yml b/internal/start/templates/kong.yml index e9848008fc..f87e95bdcc 100644 --- a/internal/start/templates/kong.yml +++ b/internal/start/templates/kong.yml @@ -112,6 +112,9 @@ services: config: add: headers: + # Apple containers resolve upstreams by IP, so the original Host + # header is an IP address. Realtime requires the tenant ID as the + # Host to route requests correctly. - "Host: {{ .RealtimeTenantId }}" replace: querystring: diff --git a/internal/utils/apple_container.go b/internal/utils/apple_container.go index ee7a41053b..ea0f7f6466 100644 --- a/internal/utils/apple_container.go +++ b/internal/utils/apple_container.go @@ -127,6 +127,7 @@ func appleRemoveVolume(ctx context.Context, volumeName string, force bool) error func appleRemoveVolumeWithRun(ctx context.Context, volumeName string, force bool, run func(context.Context, ...string) (string, error)) error { args := []string{"volume", "delete"} + // Apple container CLI does not support force-delete for volumes. _ = force args = append(args, volumeName) if _, err := run(ctx, args...); err != nil { diff --git a/internal/utils/runtime.go b/internal/utils/runtime.go index dd68baf24b..314e5143d3 100644 --- a/internal/utils/runtime.go +++ b/internal/utils/runtime.go @@ -16,6 +16,9 @@ import ( "github.com/go-errors/errors" ) +// healthcheckLabel stores the container's health-check command as a +// base64-encoded JSON array inside a label. Apple containers do not support +// native health-checks, so the CLI runs the check itself via `container exec`. const healthcheckLabel = "com.supabase.cli.healthcheck" type ContainerMount struct { @@ -100,6 +103,14 @@ func decodeHealthcheck(encoded string) ([]string, error) { return test, nil } +// The runtime dispatcher functions below use a simple if/else pattern rather +// than an interface because: +// - There are only two runtimes (Docker, Apple Container). +// - Each Apple implementation is a thin wrapper around the `container` CLI, +// keeping the logic co-located and easy to follow. +// - An interface would require threading a runtime instance through many +// call sites that currently use package-level helpers. + func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) { if UsesAppleContainerRuntime() { return appleStart(ctx, config, hostConfig, networkingConfig, containerName) From eadf34f5d125e1a5a647558311a6b06fd949f56c Mon Sep 17 00:00:00 2001 From: James Jackson Date: Tue, 17 Mar 2026 21:10:01 -0400 Subject: [PATCH 4/6] fix apple runtime lint issues --- internal/utils/apple_analytics.go | 2 +- internal/utils/apple_container.go | 12 ++++++------ internal/utils/runtime.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/utils/apple_analytics.go b/internal/utils/apple_analytics.go index 37b6aea83c..96118656ed 100644 --- a/internal/utils/apple_analytics.go +++ b/internal/utils/apple_analytics.go @@ -112,7 +112,7 @@ func StartAppleAnalyticsForwarders(containerIDs []string) error { return err } pidPath := filepath.Join(pidDir, containerID+".pid") - if err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0644); err != nil { + if err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0600); err != nil { return errors.Errorf("failed to write apple analytics pid: %w", err) } } diff --git a/internal/utils/apple_container.go b/internal/utils/apple_container.go index ea0f7f6466..fe897621a3 100644 --- a/internal/utils/apple_container.go +++ b/internal/utils/apple_container.go @@ -244,7 +244,7 @@ func appleListVolumes(ctx context.Context) ([]VolumeInfo, error) { } result := make([]VolumeInfo, 0, len(records)) for _, item := range records { - result = append(result, VolumeInfo{Name: item.Name, Labels: item.Labels}) + result = append(result, VolumeInfo(item)) } return result, nil } @@ -415,7 +415,7 @@ func appleEnsureImage(ctx context.Context, imageName string) error { return runContainerCommand(ctx, io.Discard, io.Discard, "image", "pull", imageName) } -func buildAppleContainerArgs(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, detach bool, remove bool) ([]string, error) { +func buildAppleContainerArgs(ctx context.Context, config container.Config, hostConfig container.HostConfig, _ network.NetworkingConfig, containerName string, detach bool, remove bool) ([]string, error) { applyContainerLabels(&config) imageName := GetRegistryImageUrl(config.Image) if err := appleEnsureImage(ctx, imageName); err != nil { @@ -446,11 +446,11 @@ func buildAppleContainerArgs(ctx context.Context, config container.Config, hostC if hostConfig.ReadonlyRootfs { args = append(args, "--read-only") } - if hostConfig.Resources.NanoCPUs > 0 { - args = append(args, "--cpus", strconv.FormatInt(hostConfig.Resources.NanoCPUs/1_000_000_000, 10)) + if hostConfig.NanoCPUs > 0 { + args = append(args, "--cpus", strconv.FormatInt(hostConfig.NanoCPUs/1_000_000_000, 10)) } - if hostConfig.Resources.Memory > 0 { - args = append(args, "--memory", strconv.FormatInt(hostConfig.Resources.Memory, 10)) + if hostConfig.Memory > 0 { + args = append(args, "--memory", strconv.FormatInt(hostConfig.Memory, 10)) } for path := range hostConfig.Tmpfs { args = append(args, "--tmpfs", path) diff --git a/internal/utils/runtime.go b/internal/utils/runtime.go index 314e5143d3..11d91de25f 100644 --- a/internal/utils/runtime.go +++ b/internal/utils/runtime.go @@ -219,7 +219,7 @@ func InspectContainer(ctx context.Context, containerId string) (ContainerInfo, e info.Status = resp.State.Status info.Running = resp.State.Running if resp.State.Health != nil { - info.HealthStatus = string(resp.State.Health.Status) + info.HealthStatus = resp.State.Health.Status } } if resp.NetworkSettings != nil { @@ -370,7 +370,7 @@ func AssertServiceHealthy(ctx context.Context, containerId string) error { } return nil } - if len(info.HealthStatus) > 0 && info.HealthStatus != string(types.Healthy) { + if len(info.HealthStatus) > 0 && info.HealthStatus != types.Healthy { return errors.Errorf("%s container is not ready: %s", containerId, info.HealthStatus) } return nil From 4da1bcecea84e1f85e69dda95a711774b4ea33c0 Mon Sep 17 00:00:00 2001 From: James Jackson Date: Sat, 21 Mar 2026 12:42:08 -0400 Subject: [PATCH 5/6] test: add coverage for stop.go Apple container runtime paths - Add mockable variables for stopAppleAnalyticsForwarders, listProjectVolumes, dockerRemoveAll - Test that StopAppleAnalyticsForwarders is called on Apple runtime - Test that Docker volume suggestions are shown on Docker runtime - Test that Apple container volume suggestions are shown on Apple runtime - Test correct ordering: forwarders stopped before containers Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/stop/stop.go | 12 +- internal/stop/stop_test.go | 396 ++++++++++++++++++------------------- 2 files changed, 197 insertions(+), 211 deletions(-) diff --git a/internal/stop/stop.go b/internal/stop/stop.go index 1c07c25dda..f2ca5f3ed4 100644 --- a/internal/stop/stop.go +++ b/internal/stop/stop.go @@ -11,6 +11,12 @@ import ( "github.com/supabase/cli/internal/utils/flags" ) +var ( + stopAppleAnalyticsForwarders = utils.StopAppleAnalyticsForwarders + listProjectVolumes = utils.ListProjectVolumes + dockerRemoveAll = utils.DockerRemoveAll +) + func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afero.Fs) error { var searchProjectIdFilter string if !all { @@ -23,7 +29,7 @@ func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afer searchProjectIdFilter = utils.Config.ProjectId } if utils.UsesAppleContainerRuntime() { - if err := utils.StopAppleAnalyticsForwarders(fsys); err != nil { + if err := stopAppleAnalyticsForwarders(fsys); err != nil { return err } } @@ -37,7 +43,7 @@ func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afer } fmt.Println("Stopped " + utils.Aqua("supabase") + " local development setup.") - if volumes, err := utils.ListProjectVolumes(ctx, searchProjectIdFilter); err == nil && len(volumes) > 0 { + if volumes, err := listProjectVolumes(ctx, searchProjectIdFilter); err == nil && len(volumes) > 0 { if len(searchProjectIdFilter) > 0 { if utils.UsesAppleContainerRuntime() { listVolume := fmt.Sprintf("container volume list --format json | jq '.[] | select(.labels.\"%s\" == \"%s\")'", utils.CliProjectLabel, searchProjectIdFilter) @@ -61,5 +67,5 @@ func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afer func stop(ctx context.Context, backup bool, w io.Writer, projectId string) error { utils.NoBackupVolume = !backup - return utils.DockerRemoveAll(ctx, w, projectId) + return dockerRemoveAll(ctx, w, projectId) } diff --git a/internal/stop/stop_test.go b/internal/stop/stop_test.go index e39fdd722b..806e43f535 100644 --- a/internal/stop/stop_test.go +++ b/internal/stop/stop_test.go @@ -2,241 +2,221 @@ package stop import ( "context" - "errors" - "fmt" "io" - "net/http" "testing" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/api/types/volume" - "github.com/docker/docker/client" - "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" + "github.com/supabase/cli/pkg/config" ) -func TestStopCommand(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)) - // 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{}) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/prune"). - Reply(http.StatusOK). - JSON(container.PruneReport{}) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/networks/prune"). - Reply(http.StatusOK). - JSON(network.PruneReport{}) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/volumes"). - Reply(http.StatusOK). - JSON(volume.ListResponse{Volumes: []*volume.Volume{{ - Name: utils.DbId, - }}}) +func TestRun(t *testing.T) { + t.Run("calls stop apple analytics forwarders on apple runtime", func(t *testing.T) { + // Save original state + originalRuntime := utils.Config.Local.Runtime + originalStopForwarders := stopAppleAnalyticsForwarders + originalRemoveAll := dockerRemoveAll + originalListVolumes := listProjectVolumes + var forwarderCalled bool + + // Setup cleanup + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + stopAppleAnalyticsForwarders = originalStopForwarders + dockerRemoveAll = originalRemoveAll + listProjectVolumes = originalListVolumes + }) + + // Set Apple container runtime + utils.Config.Local.Runtime = config.AppleContainerRuntime + utils.Config.ProjectId = "test-project" + + // Mock the dependencies + stopAppleAnalyticsForwarders = func(fsys afero.Fs) error { + forwarderCalled = true + return nil + } + dockerRemoveAll = func(ctx context.Context, w io.Writer, projectId string) error { + return nil + } + listProjectVolumes = func(ctx context.Context, projectId string) ([]utils.VolumeInfo, error) { + return nil, nil // No volumes to show suggestion + } + // Run test - err := Run(context.Background(), true, "", false, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) + err := Run(context.Background(), false, "test-project", false, afero.NewMemMapFs()) + + // Assert + require.NoError(t, err) + assert.True(t, forwarderCalled, "StopAppleAnalyticsForwarders should be called on Apple runtime") }) - t.Run("stops 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 initial VolumeList - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/volumes"). - Reply(http.StatusOK). - JSON(volume.ListResponse{ - Volumes: []*volume.Volume{ - {Name: "volume1", Labels: map[string]string{utils.CliProjectLabel: "project1"}}, - {Name: "volume2", Labels: map[string]string{utils.CliProjectLabel: "project2"}}, - }, - }) - - // Mock stopOneProject 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 stop - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/container-" + projectId + "/stop"). - Reply(http.StatusOK) - - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/prune"). - Reply(http.StatusOK). - JSON(container.PruneReport{}) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/networks/prune"). - Reply(http.StatusOK). - JSON(network.PruneReport{}) - gock.New(utils.Docker.DaemonHost()). - Get("/v"+utils.Docker.ClientVersion()+"/volumes"). - MatchParam("filters", fmt.Sprintf(`{"label":{"com.supabase.cli.project=%s":true}}`, projectId)). - Reply(http.StatusOK). - JSON(volume.ListResponse{Volumes: []*volume.Volume{{Name: "volume-" + projectId}}}) + t.Run("does not call stop apple analytics forwarders on docker runtime", func(t *testing.T) { + // Save original state + originalRuntime := utils.Config.Local.Runtime + originalStopForwarders := stopAppleAnalyticsForwarders + originalRemoveAll := dockerRemoveAll + originalListVolumes := listProjectVolumes + var forwarderCalled bool + + // Setup cleanup + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + stopAppleAnalyticsForwarders = originalStopForwarders + dockerRemoveAll = originalRemoveAll + listProjectVolumes = originalListVolumes + }) + + // Set Docker runtime (default) + utils.Config.Local.Runtime = config.DockerRuntime + utils.Config.ProjectId = "test-project" + + // Mock the dependencies + stopAppleAnalyticsForwarders = func(fsys afero.Fs) error { + forwarderCalled = true + return nil + } + dockerRemoveAll = func(ctx context.Context, w io.Writer, projectId string) error { + return nil + } + listProjectVolumes = func(ctx context.Context, projectId string) ([]utils.VolumeInfo, error) { + return nil, nil } - - // Mock final ContainerList to verify all containers are stopped - 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, "", true, fsys) + err := Run(context.Background(), false, "test-project", false, afero.NewMemMapFs()) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) + // Assert + require.NoError(t, err) + assert.False(t, forwarderCalled, "StopAppleAnalyticsForwarders should not be called on Docker runtime") }) - 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, "", false, fsys) - // Check error - assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there") - }) + t.Run("shows apple volume suggestion with project id on apple runtime", func(t *testing.T) { + // Save original state + originalRuntime := utils.Config.Local.Runtime + originalStopForwarders := stopAppleAnalyticsForwarders + originalRemoveAll := dockerRemoveAll + originalListVolumes := listProjectVolumes + + // Setup cleanup + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + stopAppleAnalyticsForwarders = originalStopForwarders + dockerRemoveAll = originalRemoveAll + listProjectVolumes = originalListVolumes + utils.CmdSuggestion = "" + }) + + // Set Apple container runtime + utils.Config.Local.Runtime = config.AppleContainerRuntime + utils.Config.ProjectId = "test-project" + + // Mock the dependencies + stopAppleAnalyticsForwarders = func(fsys afero.Fs) error { + return nil + } + dockerRemoveAll = func(ctx context.Context, w io.Writer, projectId string) error { + return nil + } + listProjectVolumes = func(ctx context.Context, projectId string) ([]utils.VolumeInfo, error) { + return []utils.VolumeInfo{{Name: "test-volume"}}, nil + } - t.Run("throws error on stop 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(), false, "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()) - }) -} + err := Run(context.Background(), false, "test-project", false, afero.NewMemMapFs()) -func TestStopServices(t *testing.T) { - t.Run("stops 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 + "/stop"). - Reply(http.StatusOK) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/prune"). - Reply(http.StatusOK). - JSON(container.PruneReport{}) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/networks/prune"). - Reply(http.StatusOK). - JSON(network.PruneReport{}) - // Run test - err := stop(context.Background(), true, io.Discard, utils.Config.ProjectId) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) + // Assert + require.NoError(t, err) + assert.Contains(t, utils.CmdSuggestion, "container volume list") + assert.Contains(t, utils.CmdSuggestion, "jq") + assert.Contains(t, utils.CmdSuggestion, "test-project") }) - t.Run("removes data volumes", func(t *testing.T) { - utils.DbId = "test-db" - utils.StorageId = "test-storage" - utils.EdgeRuntimeId = "test-functions" - utils.InbucketId = "test-inbucket" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStop(utils.Docker) - // Run test - err := stop(context.Background(), false, io.Discard, utils.Config.ProjectId) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) + t.Run("shows docker volume suggestion on docker runtime", func(t *testing.T) { + // Save original state + originalRuntime := utils.Config.Local.Runtime + originalStopForwarders := stopAppleAnalyticsForwarders + originalRemoveAll := dockerRemoveAll + originalListVolumes := listProjectVolumes + + // Setup cleanup + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + stopAppleAnalyticsForwarders = originalStopForwarders + dockerRemoveAll = originalRemoveAll + listProjectVolumes = originalListVolumes + utils.CmdSuggestion = "" + }) + + // Set Docker runtime + utils.Config.Local.Runtime = config.DockerRuntime + utils.Config.ProjectId = "test-project" + + // Mock the dependencies + stopAppleAnalyticsForwarders = func(fsys afero.Fs) error { + return nil + } + dockerRemoveAll = func(ctx context.Context, w io.Writer, projectId string) error { + return nil + } + listProjectVolumes = func(ctx context.Context, projectId string) ([]utils.VolumeInfo, error) { + return []utils.VolumeInfo{{Name: "test-volume"}}, nil + } - t.Run("skips all filter when removing data volumes with Docker version pre-v1.42", func(t *testing.T) { - utils.DbId = "test-db" - utils.StorageId = "test-storage" - utils.EdgeRuntimeId = "test-functions" - utils.InbucketId = "test-inbucket" - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - require.NoError(t, client.WithVersion("1.41")(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStop(utils.Docker) // Run test - err := stop(context.Background(), false, io.Discard, utils.Config.ProjectId) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) + err := Run(context.Background(), false, "test-project", false, afero.NewMemMapFs()) + + // Assert + require.NoError(t, err) + assert.Contains(t, utils.CmdSuggestion, "docker volume ls") + assert.NotContains(t, utils.CmdSuggestion, "container volume list") }) - 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.StatusOK). - JSON([]container.Summary{}) - gock.New(utils.Docker.DaemonHost()). - Post("/v" + utils.Docker.ClientVersion() + "/containers/prune"). - ReplyError(errors.New("network error")) + t.Run("stops apple analytics forwarders before docker remove all", func(t *testing.T) { + // This test verifies the order: StopAppleAnalyticsForwarders is called BEFORE stop() + // Save original state + originalRuntime := utils.Config.Local.Runtime + originalStopForwarders := stopAppleAnalyticsForwarders + originalRemoveAll := dockerRemoveAll + originalListVolumes := listProjectVolumes + + // Setup cleanup + t.Cleanup(func() { + utils.Config.Local.Runtime = originalRuntime + stopAppleAnalyticsForwarders = originalStopForwarders + dockerRemoveAll = originalRemoveAll + listProjectVolumes = originalListVolumes + }) + + // Set Apple container runtime + utils.Config.Local.Runtime = config.AppleContainerRuntime + utils.Config.ProjectId = "test-project" + + // Track call order + var callOrder []string + + // Mock the dependencies + stopAppleAnalyticsForwarders = func(fsys afero.Fs) error { + callOrder = append(callOrder, "forwarder") + return nil + } + dockerRemoveAll = func(ctx context.Context, w io.Writer, projectId string) error { + callOrder = append(callOrder, "removeAll") + return nil + } + listProjectVolumes = func(ctx context.Context, projectId string) ([]utils.VolumeInfo, error) { + return nil, nil + } + // Run test - err := stop(context.Background(), true, io.Discard, utils.Config.ProjectId) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) + err := Run(context.Background(), false, "test-project", false, afero.NewMemMapFs()) + + // Assert + require.NoError(t, err) + require.Len(t, callOrder, 2) + assert.Equal(t, "forwarder", callOrder[0], "Forwarder should be stopped before containers") + assert.Equal(t, "removeAll", callOrder[1], "Containers should be removed after forwarder stops") }) } From 9714850a36b68d8b2d0351b2a18dc37d8eae945d Mon Sep 17 00:00:00 2001 From: James Jackson Date: Sun, 22 Mar 2026 20:46:34 -0400 Subject: [PATCH 6/6] fix: align variable declarations in stop.go --- internal/stop/stop.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/stop/stop.go b/internal/stop/stop.go index f2ca5f3ed4..06bc01adc1 100644 --- a/internal/stop/stop.go +++ b/internal/stop/stop.go @@ -13,8 +13,8 @@ import ( var ( stopAppleAnalyticsForwarders = utils.StopAppleAnalyticsForwarders - listProjectVolumes = utils.ListProjectVolumes - dockerRemoveAll = utils.DockerRemoveAll + listProjectVolumes = utils.ListProjectVolumes + dockerRemoveAll = utils.DockerRemoveAll ) func Run(ctx context.Context, backup bool, projectId string, all bool, fsys afero.Fs) error {