Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions apps/cli-go/internal/db/dump/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import (
func Run(ctx context.Context, path string, config pgconn.Config, dataOnly, roleOnly, dryRun bool, fsys afero.Fs, opts ...migration.DumpOptionFunc) error {
// Initialize output stream
outStream := (io.Writer)(os.Stdout)
exec := DockerExec
// Tee pg_dump's stderr so a failed connection (e.g. an IPv6-only host that is
// unreachable from inside the container) can be classified into actionable
// guidance instead of the bare "error running container: exit 1".
var errBuf strings.Builder
exec := captureExec(&errBuf)
if dryRun {
fmt.Fprintln(os.Stderr, "DRY RUN: *only* printing the pg_dump script to console.")
exec = noExec
Expand All @@ -36,15 +40,37 @@ func Run(ctx context.Context, path string, config pgconn.Config, dataOnly, roleO
if utils.IsLocalDatabase(config) {
db = "local"
}
var err error
if dataOnly {
fmt.Fprintf(os.Stderr, "Dumping data from %s database...\n", db)
return migration.DumpData(ctx, config, outStream, exec, opts...)
err = migration.DumpData(ctx, config, outStream, exec, opts...)
} else if roleOnly {
fmt.Fprintf(os.Stderr, "Dumping roles from %s database...\n", db)
return migration.DumpRole(ctx, config, outStream, exec, opts...)
err = migration.DumpRole(ctx, config, outStream, exec, opts...)
} else {
fmt.Fprintf(os.Stderr, "Dumping schemas from %s database...\n", db)
err = migration.DumpSchema(ctx, config, outStream, exec, opts...)
}
if err != nil {
// The container exit code hides why pg_dump failed; its stderr carries
// the connection detail, so classify that for an actionable suggestion.
connErr := errors.New(errBuf.String())
utils.SetConnectSuggestion(connErr)
// For an IPv6 failure, enrich the hint with the project's actual
// transaction pooler URL so the user gets a copy-pasteable --db-url.
if utils.IsIPv6ConnectivityError(connErr) {
utils.SuggestIPv6Pooler(ctx, config.Host)
}
}
return err
}

// captureExec wraps DockerExec so the container's stderr is teed into errBuf
// (in addition to the user's terminal) for post-failure classification.
func captureExec(errBuf *strings.Builder) migration.ExecFunc {
return func(ctx context.Context, script string, env []string, w io.Writer) error {
return dockerExec(ctx, script, env, w, io.MultiWriter(os.Stderr, errBuf))
}
fmt.Fprintf(os.Stderr, "Dumping schemas from %s database...\n", db)
return migration.DumpSchema(ctx, config, outStream, exec, opts...)
}

func noExec(ctx context.Context, script string, env []string, w io.Writer) error {
Expand All @@ -69,6 +95,10 @@ func noExec(ctx context.Context, script string, env []string, w io.Writer) error
}

func DockerExec(ctx context.Context, script string, env []string, w io.Writer) error {
return dockerExec(ctx, script, env, w, os.Stderr)
}

func dockerExec(ctx context.Context, script string, env []string, w, errW io.Writer) error {
return utils.DockerRunOnceWithConfig(
ctx,
container.Config{
Expand All @@ -82,6 +112,6 @@ func DockerExec(ctx context.Context, script string, env []string, w io.Writer) e
network.NetworkingConfig{},
"",
w,
os.Stderr,
errW,
)
}
19 changes: 19 additions & 0 deletions apps/cli-go/internal/db/dump/dump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,25 @@ func TestDumpCommand(t *testing.T) {
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("suggests ipv4 pooler on ipv6 dump failure", func(t *testing.T) {
utils.CmdSuggestion = ""
t.Cleanup(func() { utils.CmdSuggestion = "" })
// Setup in-memory fs
fsys := afero.NewMemMapFs()
// Setup mock docker
require.NoError(t, apitest.MockDocker(utils.Docker))
defer gock.OffAll()
apitest.MockDockerStart(utils.Docker, imageUrl, containerId)
require.NoError(t, apitest.MockDockerErrorLogs(utils.Docker, containerId, 1,
`pg_dump: error: could not translate host name "db.test.supabase.co" to address: No address associated with hostname`))
// Run test
err := Run(context.Background(), "", dbConfig, false, false, false, fsys)
// Check error
assert.ErrorContains(t, err, "error running container: exit 1")
assert.Contains(t, utils.CmdSuggestion, "Your network does not support IPv6")
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("throws error on missing docker", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
Expand Down
27 changes: 27 additions & 0 deletions apps/cli-go/internal/testing/apitest/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,33 @@ func MockDockerLogsExitCode(docker *client.Client, containerID string, exitCode
return setupDockerLogs(docker, containerID, "", exitCode)
}

// MockDockerErrorLogs streams stderr output alongside a non-zero exit code,
// mirroring a container whose command failed (e.g. pg_dump unable to reach the
// database).
func MockDockerErrorLogs(docker *client.Client, containerID string, exitCode int, stderr string) error {
var body bytes.Buffer
writer := stdcopy.NewStdWriter(&body, stdcopy.Stderr)
if _, err := io.Copy(writer, strings.NewReader(stderr)); err != nil {
return err
}
gock.New(docker.DaemonHost()).
Get("/v"+docker.ClientVersion()+"/containers/"+containerID+"/logs").
Reply(http.StatusOK).
SetHeader("Content-Type", "application/vnd.docker.raw-stream").
Body(&body)
gock.New(docker.DaemonHost()).
Get("/v" + docker.ClientVersion() + "/containers/" + containerID + "/json").
Reply(http.StatusOK).
JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{
State: &container.State{
ExitCode: exitCode,
}}})
gock.New(docker.DaemonHost()).
Delete("/v" + docker.ClientVersion() + "/containers/" + containerID).
Reply(http.StatusOK)
return nil
}

func ListUnmatchedRequests() []string {
result := make([]string, len(gock.GetUnmatchedRequests()))
for i, r := range gock.GetUnmatchedRequests() {
Expand Down
83 changes: 83 additions & 0 deletions apps/cli-go/internal/utils/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net"
"net/url"
"os"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -173,6 +174,86 @@ func ConnectByUrl(ctx context.Context, url string, options ...func(*pgx.ConnConf

const SuggestEnvVar = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"

// ipv6LiteralPattern matches a bracketed IPv6 address (e.g. [2406:da18:...]) as
// it appears in a Go dial error such as
// "dial tcp [2406:da18:...]:5432: connect: network is unreachable".
var ipv6LiteralPattern = regexp.MustCompile(`\[[0-9a-fA-F:]*:[0-9a-fA-F:]*\]`)

// isIPv6ConnectivityError reports whether the connection failure stems from the
// host resolving to an IPv6 address that the current network cannot route to.
// Supabase direct database connections (db.<ref>.supabase.co:5432) are
// IPv6-only unless the IPv4 add-on is enabled, so users on IPv4-only networks
// (or inside a Docker container without an IPv6 stack, e.g. `supabase db dump`)
// hit these failures.
func isIPv6ConnectivityError(msg string) bool {
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "address family for hostname not supported"),
strings.Contains(lower, "no address associated with hostname"):
// getaddrinfo inside the pg_dump container when the host is IPv6-only and
// the container has no IPv6 stack, so AI_ADDRCONFIG filters out the AAAA
// record: "could not translate host name ... to address: Address family
// for hostname not supported" / "... No address associated with hostname".
return true
case strings.Contains(lower, "network is unreachable"):
return true
case strings.Contains(lower, "no route to host"):
// Require an IPv6 literal so genuine project-not-found errors (which the
// branch below maps) keep their existing suggestion.
return ipv6LiteralPattern.MatchString(msg)
}
return false
}

// IsIPv6ConnectivityError reports whether err is a database connection failure
// caused by an IPv6 address the current network (or container) cannot reach.
func IsIPv6ConnectivityError(err error) bool {
if err == nil {
return false
}
return isIPv6ConnectivityError(err.Error())
}

// ipv6Suggestion is the generic, command-agnostic hint shown when a direct
// connection fails because the host is IPv6-only. It points users at the IPv4
// transaction pooler via --db-url; SuggestIPv6Pooler upgrades it with the
// project's actual connection string when one can be fetched.
func ipv6Suggestion() string {
return fmt.Sprintf(
"Your network does not support IPv6, which is required for direct connections to the database.\n"+
"Retry with your project's IPv4 transaction pooler connection string via %s.\n"+
"You can copy it from the dashboard under Connect > Transaction pooler.",
Aqua("--db-url"),
)
}

// ipv6PoolerSuggestion is the IPv6 hint enriched with the project's actual
// transaction pooler connection string, ready to paste into --db-url.
func ipv6PoolerSuggestion(connString string) string {
return fmt.Sprintf(
"Your network does not support IPv6, which is required for direct connections to the database.\n"+
"Retry through the IPv4 transaction pooler by passing it to %s\n"+
"(replace [YOUR-PASSWORD] with your database password).",
Aqua(fmt.Sprintf(`--db-url "%s"`, connString)),
)
}

// SuggestIPv6Pooler upgrades CmdSuggestion with the project's transaction pooler
// connection string when host is a Supabase direct database host and the pooler
// config can be fetched. Returns true when the suggestion was set.
func SuggestIPv6Pooler(ctx context.Context, host string) bool {
matches := ProjectHostPattern.FindStringSubmatch(host)
if len(matches) < 3 {
return false
}
primary, err := GetPoolerConfigPrimary(ctx, matches[2])
if err != nil || len(primary.ConnectionString) == 0 {
return false
}
CmdSuggestion = ipv6PoolerSuggestion(primary.ConnectionString)
return true
}

// Sets CmdSuggestion to an actionable hint based on the given pg connection error.
func SetConnectSuggestion(err error) {
if err == nil {
Expand All @@ -190,6 +271,8 @@ func SetConnectSuggestion(err error) {
} else if strings.Contains(msg, "SCRAM exchange: Wrong password") || strings.Contains(msg, "failed SASL auth") {
// password authentication failed for user / invalid SCRAM server-final-message received from server
CmdSuggestion = SuggestEnvVar
} else if isIPv6ConnectivityError(msg) {
CmdSuggestion = ipv6Suggestion()
} else if strings.Contains(msg, "connect: no route to host") || strings.Contains(msg, "Tenant or user not found") {
// Assumes IPv6 check has been performed before this
CmdSuggestion = "Make sure your project exists on profile: " + CurrentProfile.Name
Expand Down
68 changes: 67 additions & 1 deletion apps/cli-go/internal/utils/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/supabase/cli/internal/testing/apitest"
"github.com/supabase/cli/internal/utils/cloudflare"
"github.com/supabase/cli/pkg/api"
"github.com/supabase/cli/pkg/pgtest"
)

Expand Down Expand Up @@ -221,7 +222,32 @@ func TestSetConnectSuggestion(t *testing.T) {
suggestion: "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD",
},
{
name: "no route to host",
name: "ipv6 no route to host",
err: errors.New("dial tcp [2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432: connect: no route to host"),
suggestion: "Your network does not support IPv6",
},
{
name: "ipv6 network is unreachable",
err: errors.New("dial tcp [2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432: connect: network is unreachable"),
suggestion: "Your network does not support IPv6",
},
{
name: "libpq unsupported address family",
err: errors.New(`pg_dump: error: connection to server failed: could not translate host name "db.test.supabase.co" to address: Address family for hostname not supported`),
suggestion: "Your network does not support IPv6",
},
{
name: "libpq no address associated with hostname",
err: errors.New(`pg_dump: error: could not translate host name "db.ngpopfcjxrfmzmhmmpct.supabase.co" to address: No address associated with hostname`),
suggestion: "Your network does not support IPv6",
},
{
name: "libpq network is unreachable without literal",
err: errors.New(`connection to server at "db.test.supabase.co", port 5432 failed: Network is unreachable`),
suggestion: "Your network does not support IPv6",
},
{
name: "no route to host without ipv6 address",
err: errors.New("connect: no route to host"),
suggestion: "Make sure your project exists on profile: " + CurrentProfile.Name,
},
Expand All @@ -245,6 +271,46 @@ func TestSetConnectSuggestion(t *testing.T) {
}
}

func TestSuggestIPv6Pooler(t *testing.T) {
ref := apitest.RandomProjectRef()
poolerURL := "postgres://postgres." + ref + ":[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres"

t.Run("enriches suggestion with transaction pooler url", func(t *testing.T) {
CmdSuggestion = ""
t.Cleanup(func() { CmdSuggestion = "" })
t.Cleanup(apitest.MockPlatformAPI(t))
gock.New(DefaultApiHost).
Get("/v1/projects/" + ref + "/config/database/pooler").
Reply(http.StatusOK).
JSON([]api.SupavisorConfigResponse{{
DatabaseType: api.SupavisorConfigResponseDatabaseTypePRIMARY,
ConnectionString: poolerURL,
}})
ok := SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co")
assert.True(t, ok)
assert.Contains(t, CmdSuggestion, "--db-url")
assert.Contains(t, CmdSuggestion, poolerURL)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("skips non-supabase host without api call", func(t *testing.T) {
CmdSuggestion = ""
assert.False(t, SuggestIPv6Pooler(context.Background(), "localhost"))
assert.Empty(t, CmdSuggestion)
})

t.Run("returns false when pooler config is unavailable", func(t *testing.T) {
CmdSuggestion = ""
t.Cleanup(apitest.MockPlatformAPI(t))
gock.New(DefaultApiHost).
Get("/v1/projects/" + ref + "/config/database/pooler").
Reply(http.StatusOK).
JSON([]api.SupavisorConfigResponse{})
assert.False(t, SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co"))
assert.Empty(t, CmdSuggestion)
})
}

func TestPostgresURL(t *testing.T) {
url := ToPostgresURL(pgconn.Config{
Host: "2406:da18:4fd:9b0d:80ec:9812:3e65:450b",
Expand Down
Loading