supabase-cli/internal/link/link.go

287 lines
9.3 KiB
Go

package link
import (
"context"
"fmt"
"net/http"
"os"
"strconv"
"sync"
"github.com/go-errors/errors"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/credentials"
"github.com/supabase/cli/internal/utils/flags"
"github.com/supabase/cli/internal/utils/tenant"
"github.com/supabase/cli/pkg/api"
"github.com/supabase/cli/pkg/cast"
cliConfig "github.com/supabase/cli/pkg/config"
"github.com/supabase/cli/pkg/diff"
"github.com/supabase/cli/pkg/migration"
)
func Run(ctx context.Context, projectRef string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
copy := utils.Config.Clone()
original, err := cliConfig.ToTomlBytes(copy)
if err != nil {
fmt.Fprintln(utils.GetDebugLogger(), err)
}
if err := checkRemoteProjectStatus(ctx, projectRef, fsys); err != nil {
return err
}
// 1. Check service config
keys, err := tenant.GetApiKeys(ctx, projectRef)
if err != nil {
return err
}
LinkServices(ctx, projectRef, keys.Anon, fsys)
// 2. Check database connection
config := flags.GetDbConfigOptionalPassword(projectRef)
if len(config.Password) > 0 {
if err := linkDatabase(ctx, config, options...); err != nil {
return err
}
// Save database password
if err := credentials.StoreProvider.Set(projectRef, config.Password); err != nil {
fmt.Fprintln(os.Stderr, "Failed to save database password:", err)
}
}
// 3. Save project ref
if err := utils.WriteFile(utils.ProjectRefPath, []byte(projectRef), fsys); err != nil {
return err
}
fmt.Fprintln(os.Stdout, "Finished "+utils.Aqua("supabase link")+".")
// 4. Suggest config update
updated, err := cliConfig.ToTomlBytes(utils.Config.Clone())
if err != nil {
fmt.Fprintln(utils.GetDebugLogger(), err)
}
if lineDiff := diff.Diff(utils.ConfigPath, original, projectRef, updated); len(lineDiff) > 0 {
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Local config differs from linked project. Try updating", utils.Bold(utils.ConfigPath))
fmt.Println(string(lineDiff))
}
return nil
}
func LinkServices(ctx context.Context, projectRef, anonKey string, fsys afero.Fs) {
// Ignore non-fatal errors linking services
var wg sync.WaitGroup
wg.Add(8)
go func() {
defer wg.Done()
if err := linkDatabaseSettings(ctx, projectRef); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
go func() {
defer wg.Done()
if err := linkPostgrest(ctx, projectRef); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
go func() {
defer wg.Done()
if err := linkGotrue(ctx, projectRef); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
go func() {
defer wg.Done()
if err := linkStorage(ctx, projectRef); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
go func() {
defer wg.Done()
if err := linkPooler(ctx, projectRef, fsys); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
api := tenant.NewTenantAPI(ctx, projectRef, anonKey)
go func() {
defer wg.Done()
if err := linkPostgrestVersion(ctx, api, fsys); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
go func() {
defer wg.Done()
if err := linkGotrueVersion(ctx, api, fsys); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
go func() {
defer wg.Done()
if err := linkStorageVersion(ctx, api, fsys); err != nil && viper.GetBool("DEBUG") {
fmt.Fprintln(os.Stderr, err)
}
}()
wg.Wait()
}
func linkPostgrest(ctx context.Context, projectRef string) error {
resp, err := utils.GetSupabase().V1GetPostgrestServiceConfigWithResponse(ctx, projectRef)
if err != nil {
return errors.Errorf("failed to read API config: %w", err)
} else if resp.JSON200 == nil {
return errors.Errorf("unexpected API config status %d: %s", resp.StatusCode(), string(resp.Body))
}
utils.Config.Api.FromRemoteApiConfig(*resp.JSON200)
return nil
}
func linkPostgrestVersion(ctx context.Context, api tenant.TenantAPI, fsys afero.Fs) error {
version, err := api.GetPostgrestVersion(ctx)
if err != nil {
return err
}
return utils.WriteFile(utils.RestVersionPath, []byte(version), fsys)
}
func linkGotrue(ctx context.Context, projectRef string) error {
resp, err := utils.GetSupabase().V1GetAuthServiceConfigWithResponse(ctx, projectRef)
if err != nil {
return errors.Errorf("failed to read Auth config: %w", err)
} else if resp.JSON200 == nil {
return errors.Errorf("unexpected Auth config status %d: %s", resp.StatusCode(), string(resp.Body))
}
utils.Config.Auth.FromRemoteAuthConfig(*resp.JSON200)
return nil
}
func linkGotrueVersion(ctx context.Context, api tenant.TenantAPI, fsys afero.Fs) error {
version, err := api.GetGotrueVersion(ctx)
if err != nil {
return err
}
return utils.WriteFile(utils.GotrueVersionPath, []byte(version), fsys)
}
func linkStorage(ctx context.Context, projectRef string) error {
resp, err := utils.GetSupabase().V1GetStorageConfigWithResponse(ctx, projectRef)
if err != nil {
return errors.Errorf("failed to read Storage config: %w", err)
} else if resp.JSON200 == nil {
return errors.Errorf("unexpected Storage config status %d: %s", resp.StatusCode(), string(resp.Body))
}
utils.Config.Storage.FromRemoteStorageConfig(*resp.JSON200)
return nil
}
func linkStorageVersion(ctx context.Context, api tenant.TenantAPI, fsys afero.Fs) error {
version, err := api.GetStorageVersion(ctx)
if err != nil {
return err
}
return utils.WriteFile(utils.StorageVersionPath, []byte(version), fsys)
}
func linkDatabaseSettings(ctx context.Context, projectRef string) error {
resp, err := utils.GetSupabase().V1GetPostgresConfigWithResponse(ctx, projectRef)
if err != nil {
return errors.Errorf("failed to read DB config: %w", err)
} else if resp.JSON200 == nil {
return errors.Errorf("unexpected DB config status %d: %s", resp.StatusCode(), string(resp.Body))
}
utils.Config.Db.Settings.FromRemotePostgresConfig(*resp.JSON200)
return nil
}
func linkDatabase(ctx context.Context, config pgconn.Config, options ...func(*pgx.ConnConfig)) error {
conn, err := utils.ConnectByConfig(ctx, config, options...)
if err != nil {
return err
}
defer conn.Close(context.Background())
updatePostgresConfig(conn)
// If `schema_migrations` doesn't exist on the remote database, create it.
if err := migration.CreateMigrationTable(ctx, conn); err != nil {
return err
}
return migration.CreateSeedTable(ctx, conn)
}
func updatePostgresConfig(conn *pgx.Conn) {
serverVersion := conn.PgConn().ParameterStatus("server_version")
// Safe to assume that supported Postgres version is 10.0 <= n < 100.0
majorDigits := len(serverVersion)
if majorDigits > 2 {
majorDigits = 2
}
// Treat error as unchanged
if dbMajorVersion, err := strconv.ParseUint(serverVersion[:majorDigits], 10, 7); err == nil {
utils.Config.Db.MajorVersion = uint(dbMajorVersion)
}
}
func linkPooler(ctx context.Context, projectRef string, fsys afero.Fs) error {
resp, err := utils.GetSupabase().V1GetSupavisorConfigWithResponse(ctx, projectRef)
if err != nil {
return errors.Errorf("failed to get pooler config: %w", err)
}
if resp.JSON200 == nil {
return errors.Errorf("%w: %s", tenant.ErrAuthToken, string(resp.Body))
}
for _, config := range *resp.JSON200 {
if config.DatabaseType == api.PRIMARY {
updatePoolerConfig(config)
}
}
return utils.WriteFile(utils.PoolerUrlPath, []byte(utils.Config.Db.Pooler.ConnectionString), fsys)
}
func updatePoolerConfig(config api.SupavisorConfigResponse) {
utils.Config.Db.Pooler.ConnectionString = config.ConnectionString
utils.Config.Db.Pooler.PoolMode = cliConfig.PoolMode(config.PoolMode)
if config.DefaultPoolSize != nil {
utils.Config.Db.Pooler.DefaultPoolSize = cast.IntToUint(*config.DefaultPoolSize)
}
if config.MaxClientConn != nil {
utils.Config.Db.Pooler.MaxClientConn = cast.IntToUint(*config.MaxClientConn)
}
}
var errProjectPaused = errors.New("project is paused")
func checkRemoteProjectStatus(ctx context.Context, projectRef string, fsys afero.Fs) error {
resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, projectRef)
if err != nil {
return errors.Errorf("failed to retrieve remote project status: %w", err)
}
switch resp.StatusCode() {
case http.StatusNotFound:
// Ignore not found error to support linking branch projects
return nil
case http.StatusOK:
// resp.JSON200 is not nil, proceed
default:
return errors.New("Unexpected error retrieving remote project status: " + string(resp.Body))
}
switch resp.JSON200.Status {
case api.V1ProjectWithDatabaseResponseStatusINACTIVE:
utils.CmdSuggestion = fmt.Sprintf("An admin must unpause it from the Supabase dashboard at %s", utils.Aqua(fmt.Sprintf("%s/project/%s", utils.GetSupabaseDashboardURL(), projectRef)))
return errors.New(errProjectPaused)
case api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY:
// Project is in the desired state, do nothing
default:
fmt.Fprintf(os.Stderr, "%s: Project status is %s instead of Active Healthy. Some operations might fail.\n", utils.Yellow("WARNING"), resp.JSON200.Status)
}
// Update postgres image version to match the remote project
if version := resp.JSON200.Database.Version; len(version) > 0 {
return utils.WriteFile(utils.PostgresVersionPath, []byte(version), fsys)
}
return nil
}