386 lines
12 KiB
Go
386 lines
12 KiB
Go
package start
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/network"
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/go-connections/nat"
|
|
"github.com/go-errors/errors"
|
|
"github.com/jackc/pgconn"
|
|
"github.com/jackc/pgx/v4"
|
|
"github.com/spf13/afero"
|
|
"github.com/supabase/cli/internal/migration/apply"
|
|
"github.com/supabase/cli/internal/status"
|
|
"github.com/supabase/cli/internal/utils"
|
|
"github.com/supabase/cli/internal/utils/flags"
|
|
"github.com/supabase/cli/pkg/migration"
|
|
"github.com/supabase/cli/pkg/vault"
|
|
)
|
|
|
|
var (
|
|
HealthTimeout = 120 * time.Second
|
|
//go:embed templates/schema.sql
|
|
initialSchema string
|
|
//go:embed templates/webhook.sql
|
|
webhookSchema string
|
|
//go:embed templates/_supabase.sql
|
|
_supabaseSchema string
|
|
//go:embed templates/restore.sh
|
|
restoreScript string
|
|
)
|
|
|
|
func Run(ctx context.Context, fromBackup string, fsys afero.Fs) error {
|
|
if err := flags.LoadConfig(fsys); err != nil {
|
|
return err
|
|
}
|
|
if err := utils.AssertSupabaseDbIsRunning(); err == nil {
|
|
fmt.Fprintln(os.Stderr, "Postgres database is already running.")
|
|
return nil
|
|
} else if !errors.Is(err, utils.ErrNotRunning) {
|
|
return err
|
|
}
|
|
err := StartDatabase(ctx, fromBackup, fsys, os.Stderr)
|
|
if err != nil {
|
|
if err := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func NewContainerConfig() container.Config {
|
|
env := []string{
|
|
"POSTGRES_PASSWORD=" + utils.Config.Db.Password,
|
|
"POSTGRES_HOST=/var/run/postgresql",
|
|
"JWT_SECRET=" + utils.Config.Auth.JwtSecret,
|
|
fmt.Sprintf("JWT_EXP=%d", utils.Config.Auth.JwtExpiry),
|
|
}
|
|
if len(utils.Config.Experimental.OrioleDBVersion) > 0 {
|
|
env = append(env,
|
|
"POSTGRES_INITDB_ARGS=--lc-collate=C",
|
|
fmt.Sprintf("S3_ENABLED=%t", true),
|
|
"S3_HOST="+utils.Config.Experimental.S3Host,
|
|
"S3_REGION="+utils.Config.Experimental.S3Region,
|
|
"S3_ACCESS_KEY="+utils.Config.Experimental.S3AccessKey,
|
|
"S3_SECRET_KEY="+utils.Config.Experimental.S3SecretKey,
|
|
)
|
|
} else {
|
|
env = append(env, "POSTGRES_INITDB_ARGS=--lc-collate=C.UTF-8")
|
|
}
|
|
config := container.Config{
|
|
Image: utils.Config.Db.Image,
|
|
Env: env,
|
|
Healthcheck: &container.HealthConfig{
|
|
Test: []string{"CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"},
|
|
Interval: 10 * time.Second,
|
|
Timeout: 2 * time.Second,
|
|
Retries: 3,
|
|
},
|
|
Entrypoint: []string{"sh", "-c", `
|
|
cat <<'EOF' > /etc/postgresql.schema.sql && \
|
|
cat <<'EOF' > /etc/postgresql-custom/pgsodium_root.key && \
|
|
cat <<'EOF' >> /etc/postgresql/postgresql.conf && \
|
|
docker-entrypoint.sh postgres -D /etc/postgresql
|
|
` + initialSchema + `
|
|
` + webhookSchema + `
|
|
` + _supabaseSchema + `
|
|
EOF
|
|
` + utils.Config.Db.RootKey + `
|
|
EOF
|
|
` + utils.Config.Db.Settings.ToPostgresConfig() + `
|
|
EOF`},
|
|
}
|
|
if utils.Config.Db.MajorVersion >= 14 {
|
|
config.Cmd = []string{"postgres",
|
|
"-c", "config_file=/etc/postgresql/postgresql.conf",
|
|
// Ref: https://postgrespro.com/list/thread-id/2448092
|
|
"-c", `search_path="$user",public,extensions`,
|
|
}
|
|
}
|
|
return config
|
|
}
|
|
|
|
func NewHostConfig() container.HostConfig {
|
|
hostPort := strconv.FormatUint(uint64(utils.Config.Db.Port), 10)
|
|
hostConfig := container.HostConfig{
|
|
PortBindings: nat.PortMap{"5432/tcp": []nat.PortBinding{{HostPort: hostPort}}},
|
|
RestartPolicy: container.RestartPolicy{Name: "always"},
|
|
Binds: []string{
|
|
utils.DbId + ":/var/lib/postgresql/data",
|
|
utils.ConfigId + ":/etc/postgresql-custom",
|
|
},
|
|
}
|
|
return hostConfig
|
|
}
|
|
|
|
func StartDatabase(ctx context.Context, fromBackup string, fsys afero.Fs, w io.Writer, options ...func(*pgx.ConnConfig)) error {
|
|
config := NewContainerConfig()
|
|
hostConfig := NewHostConfig()
|
|
networkingConfig := network.NetworkingConfig{
|
|
EndpointsConfig: map[string]*network.EndpointSettings{
|
|
utils.NetId: {
|
|
Aliases: utils.DbAliases,
|
|
},
|
|
},
|
|
}
|
|
if utils.Config.Db.MajorVersion <= 14 {
|
|
config.Entrypoint = []string{"sh", "-c", `
|
|
cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \
|
|
cat <<'EOF' >> /etc/postgresql/postgresql.conf && \
|
|
docker-entrypoint.sh postgres -D /etc/postgresql
|
|
` + _supabaseSchema + `
|
|
EOF
|
|
` + utils.Config.Db.Settings.ToPostgresConfig() + `
|
|
EOF`}
|
|
hostConfig.Tmpfs = map[string]string{"/docker-entrypoint-initdb.d": ""}
|
|
}
|
|
if len(fromBackup) > 0 {
|
|
config.Entrypoint = []string{"sh", "-c", `
|
|
cat <<'EOF' > /etc/postgresql.schema.sql && \
|
|
cat <<'EOF' > /docker-entrypoint-initdb.d/migrate.sh && \
|
|
cat <<'EOF' > /etc/postgresql-custom/pgsodium_root.key && \
|
|
cat <<'EOF' >> /etc/postgresql/postgresql.conf && \
|
|
docker-entrypoint.sh postgres -D /etc/postgresql
|
|
` + initialSchema + `
|
|
` + _supabaseSchema + `
|
|
EOF
|
|
` + restoreScript + `
|
|
EOF
|
|
` + utils.Config.Db.RootKey + `
|
|
EOF
|
|
` + utils.Config.Db.Settings.ToPostgresConfig() + `
|
|
EOF`}
|
|
if !filepath.IsAbs(fromBackup) {
|
|
fromBackup = filepath.Join(utils.CurrentDirAbs, fromBackup)
|
|
}
|
|
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 = client.IsErrNotFound(err)
|
|
if utils.NoBackupVolume {
|
|
fmt.Fprintln(w, "Starting database...")
|
|
} else if len(fromBackup) > 0 {
|
|
utils.CmdSuggestion = fmt.Sprintf("Run %s to remove existing docker volumes.", utils.Aqua("supabase stop --no-backup"))
|
|
return errors.Errorf("backup volume already exists")
|
|
} else {
|
|
fmt.Fprintln(w, "Starting database from backup...")
|
|
}
|
|
if _, err := utils.DockerStart(ctx, config, hostConfig, networkingConfig, utils.DbId); err != nil {
|
|
return err
|
|
}
|
|
if err := WaitForHealthyService(ctx, HealthTimeout, utils.DbId); err != nil {
|
|
return err
|
|
}
|
|
// Initialize if we are on PG14 and there's no existing db volume
|
|
if len(fromBackup) > 0 {
|
|
if err := initSchema15(ctx, utils.DbId); err != nil {
|
|
return err
|
|
}
|
|
} else if utils.NoBackupVolume {
|
|
if err := SetupLocalDatabase(ctx, "", fsys, w, options...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return initCurrentBranch(fsys)
|
|
}
|
|
|
|
func NewBackoffPolicy(ctx context.Context, timeout time.Duration) backoff.BackOff {
|
|
policy := backoff.WithMaxRetries(
|
|
backoff.NewConstantBackOff(time.Second),
|
|
uint64(timeout.Seconds()),
|
|
)
|
|
return backoff.WithContext(policy, ctx)
|
|
}
|
|
|
|
func WaitForHealthyService(ctx context.Context, timeout time.Duration, started ...string) error {
|
|
probe := func() error {
|
|
var errHealth []error
|
|
var unhealthy []string
|
|
for _, container := range started {
|
|
if err := status.IsServiceReady(ctx, container); err != nil {
|
|
unhealthy = append(unhealthy, container)
|
|
errHealth = append(errHealth, err)
|
|
}
|
|
}
|
|
started = unhealthy
|
|
return errors.Join(errHealth...)
|
|
}
|
|
policy := NewBackoffPolicy(ctx, timeout)
|
|
err := backoff.Retry(probe, policy)
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
// Print container logs for easier debugging
|
|
for _, containerId := range started {
|
|
fmt.Fprintln(os.Stderr, containerId, "container logs:")
|
|
if err := utils.DockerStreamLogsOnce(context.Background(), containerId, os.Stderr, os.Stderr); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func IsUnhealthyError(err error) bool {
|
|
// Health check always returns a joinError
|
|
_, ok := err.(interface{ Unwrap() []error })
|
|
return ok
|
|
}
|
|
|
|
func initCurrentBranch(fsys afero.Fs) error {
|
|
// Create _current_branch file to avoid breaking db branch commands
|
|
if _, err := fsys.Stat(utils.CurrBranchPath); err == nil {
|
|
return nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return errors.Errorf("failed init current branch: %w", err)
|
|
}
|
|
return utils.WriteFile(utils.CurrBranchPath, []byte("main"), fsys)
|
|
}
|
|
|
|
func initSchema(ctx context.Context, conn *pgx.Conn, host string, w io.Writer) error {
|
|
fmt.Fprintln(w, "Initialising schema...")
|
|
if utils.Config.Db.MajorVersion <= 14 {
|
|
if file, err := migration.NewMigrationFromReader(strings.NewReader(utils.GlobalsSql)); err != nil {
|
|
return err
|
|
} else if err := file.ExecBatch(ctx, conn); err != nil {
|
|
return err
|
|
}
|
|
return InitSchema14(ctx, conn)
|
|
}
|
|
return initSchema15(ctx, host)
|
|
}
|
|
|
|
func InitSchema14(ctx context.Context, conn *pgx.Conn) error {
|
|
sql := utils.InitialSchemaPg14Sql
|
|
if utils.Config.Db.MajorVersion == 13 {
|
|
sql = utils.InitialSchemaPg13Sql
|
|
}
|
|
file, err := migration.NewMigrationFromReader(strings.NewReader(sql))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return file.ExecBatch(ctx, conn)
|
|
}
|
|
|
|
func initRealtimeJob(host string) utils.DockerJob {
|
|
return utils.DockerJob{
|
|
Image: utils.Config.Realtime.Image,
|
|
Env: []string{
|
|
"PORT=4000",
|
|
"DB_HOST=" + host,
|
|
"DB_PORT=5432",
|
|
"DB_USER=supabase_admin",
|
|
"DB_PASSWORD=" + utils.Config.Db.Password,
|
|
"DB_NAME=postgres",
|
|
"DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime",
|
|
"DB_ENC_KEY=" + utils.Config.Realtime.EncryptionKey,
|
|
"API_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
|
|
"METRICS_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
|
|
"APP_NAME=realtime",
|
|
"SECRET_KEY_BASE=" + utils.Config.Realtime.SecretKeyBase,
|
|
"ERL_AFLAGS=" + utils.ToRealtimeEnv(utils.Config.Realtime.IpVersion),
|
|
"DNS_NODES=''",
|
|
"RLIMIT_NOFILE=",
|
|
"SEED_SELF_HOST=true",
|
|
"RUN_JANITOR=true",
|
|
fmt.Sprintf("MAX_HEADER_LENGTH=%d", utils.Config.Realtime.MaxHeaderLength),
|
|
},
|
|
Cmd: []string{"/app/bin/realtime", "eval", fmt.Sprintf(`{:ok, _} = Application.ensure_all_started(:realtime)
|
|
{:ok, _} = Realtime.Tenants.health_check("%s")`, utils.Config.Realtime.TenantId)},
|
|
}
|
|
}
|
|
|
|
func initStorageJob(host string) utils.DockerJob {
|
|
return utils.DockerJob{
|
|
Image: utils.Config.Storage.Image,
|
|
Env: []string{
|
|
"DB_INSTALL_ROLES=false",
|
|
"ANON_KEY=" + utils.Config.Auth.AnonKey,
|
|
"SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey,
|
|
"PGRST_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
|
|
fmt.Sprintf("DATABASE_URL=postgresql://supabase_storage_admin:%s@%s:5432/postgres", utils.Config.Db.Password, host),
|
|
fmt.Sprintf("FILE_SIZE_LIMIT=%v", utils.Config.Storage.FileSizeLimit),
|
|
"STORAGE_BACKEND=file",
|
|
"STORAGE_FILE_BACKEND_PATH=/mnt",
|
|
"TENANT_ID=stub",
|
|
// TODO: https://github.com/supabase/storage-api/issues/55
|
|
"REGION=stub",
|
|
"GLOBAL_S3_BUCKET=stub",
|
|
},
|
|
Cmd: []string{"node", "dist/scripts/migrate-call.js"},
|
|
}
|
|
}
|
|
|
|
func initAuthJob(host string) utils.DockerJob {
|
|
return utils.DockerJob{
|
|
Image: utils.Config.Auth.Image,
|
|
Env: []string{
|
|
"API_EXTERNAL_URL=" + utils.Config.Api.ExternalUrl,
|
|
"GOTRUE_LOG_LEVEL=error",
|
|
"GOTRUE_DB_DRIVER=postgres",
|
|
fmt.Sprintf("GOTRUE_DB_DATABASE_URL=postgresql://supabase_auth_admin:%s@%s:5432/postgres", utils.Config.Db.Password, host),
|
|
"GOTRUE_SITE_URL=" + utils.Config.Auth.SiteUrl,
|
|
"GOTRUE_JWT_SECRET=" + utils.Config.Auth.JwtSecret,
|
|
},
|
|
Cmd: []string{"gotrue", "migrate"},
|
|
}
|
|
}
|
|
|
|
func initSchema15(ctx context.Context, host string) error {
|
|
// Apply service migrations
|
|
var initJobs []utils.DockerJob
|
|
if utils.Config.Realtime.Enabled {
|
|
initJobs = append(initJobs, initRealtimeJob(host))
|
|
}
|
|
if utils.Config.Storage.Enabled {
|
|
initJobs = append(initJobs, initStorageJob(host))
|
|
}
|
|
if utils.Config.Auth.Enabled {
|
|
initJobs = append(initJobs, initAuthJob(host))
|
|
}
|
|
logger := utils.GetDebugLogger()
|
|
for _, job := range initJobs {
|
|
if err := utils.DockerRunJob(ctx, job, io.Discard, logger); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func SetupLocalDatabase(ctx context.Context, version string, fsys afero.Fs, w io.Writer, options ...func(*pgx.ConnConfig)) error {
|
|
conn, err := utils.ConnectLocalPostgres(ctx, pgconn.Config{}, options...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close(context.Background())
|
|
if err := SetupDatabase(ctx, conn, utils.DbId, w, fsys); err != nil {
|
|
return err
|
|
}
|
|
return apply.MigrateAndSeed(ctx, version, conn, fsys)
|
|
}
|
|
|
|
func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer, fsys afero.Fs) error {
|
|
if err := initSchema(ctx, conn, host, w); err != nil {
|
|
return err
|
|
}
|
|
// Create vault secrets first so roles.sql can reference them
|
|
if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil {
|
|
return err
|
|
}
|
|
err := migration.SeedGlobals(ctx, []string{utils.CustomRolesPath}, conn, afero.NewIOFS(fsys))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|