github.com/Redstoneguy129/cli@v0.0.0-20230211220159-15dca4e91917/internal/start/start.go (about)

     1  package start
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	_ "embed"
     7  	"errors"
     8  	"fmt"
     9  	"os"
    10  	"strconv"
    11  	"strings"
    12  	"text/template"
    13  	"time"
    14  
    15  	"github.com/Redstoneguy129/cli/internal/db/reset"
    16  	"github.com/Redstoneguy129/cli/internal/db/start"
    17  	"github.com/Redstoneguy129/cli/internal/status"
    18  	"github.com/Redstoneguy129/cli/internal/utils"
    19  	"github.com/docker/docker/api/types/container"
    20  	"github.com/docker/go-connections/nat"
    21  	"github.com/jackc/pgx/v4"
    22  	"github.com/spf13/afero"
    23  )
    24  
    25  var errUnhealthy = errors.New("service not healthy")
    26  
    27  func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignoreHealthCheck bool) error {
    28  	// Sanity checks.
    29  	{
    30  		if err := utils.LoadConfigFS(fsys); err != nil {
    31  			return err
    32  		}
    33  		if err := utils.AssertDockerIsRunning(ctx); err != nil {
    34  			return err
    35  		}
    36  		if _, err := utils.Docker.ContainerInspect(ctx, utils.DbId); err == nil {
    37  			fmt.Fprintln(os.Stderr, utils.Aqua("supabase start")+" is already running.")
    38  			return nil
    39  		}
    40  	}
    41  
    42  	if err := utils.RunProgram(ctx, func(p utils.Program, ctx context.Context) error {
    43  		return run(p, ctx, fsys, excludedContainers)
    44  	}); err != nil {
    45  		if ignoreHealthCheck && errors.Is(err, errUnhealthy) {
    46  			fmt.Fprintln(os.Stderr, err)
    47  		} else {
    48  			utils.DockerRemoveAll(context.Background())
    49  			return err
    50  		}
    51  	}
    52  
    53  	fmt.Fprintf(os.Stderr, "Started %s local development setup.\n\n", utils.Aqua("supabase"))
    54  	status.PrettyPrint(os.Stdout, excludedContainers...)
    55  	return nil
    56  }
    57  
    58  var (
    59  	// TODO: Unhardcode keys
    60  	//go:embed templates/kong_config
    61  	kongConfigEmbed    string
    62  	kongConfigTemplate = template.Must(template.New("kongConfig").Parse(kongConfigEmbed))
    63  )
    64  
    65  func run(p utils.Program, ctx context.Context, fsys afero.Fs, excludedContainers []string, options ...func(*pgx.ConnConfig)) error {
    66  	excluded := make(map[string]bool)
    67  	for _, name := range excludedContainers {
    68  		excluded[name] = true
    69  	}
    70  
    71  	// Pull images.
    72  	{
    73  		total := len(utils.ServiceImages) + 1
    74  		p.Send(utils.StatusMsg(fmt.Sprintf("Pulling images... (1/%d)", total)))
    75  		if err := utils.DockerPullImageIfNotCached(ctx, utils.DbImage); err != nil {
    76  			return err
    77  		}
    78  		for i, image := range utils.ServiceImages {
    79  			if isContainerExcluded(image, excluded) {
    80  				fmt.Fprintln(os.Stderr, "Excluding container:", image)
    81  				continue
    82  			}
    83  			p.Send(utils.StatusMsg(fmt.Sprintf("Pulling images... (%d/%d)", i+1, total)))
    84  			if err := utils.DockerPullImageIfNotCached(ctx, image); err != nil {
    85  				return err
    86  			}
    87  		}
    88  	}
    89  
    90  	// Start Postgres.
    91  	w := utils.StatusWriter{Program: p}
    92  	if err := start.StartDatabase(ctx, fsys, w, options...); err != nil {
    93  		return err
    94  	}
    95  
    96  	p.Send(utils.StatusMsg("Starting containers..."))
    97  	var started []string
    98  
    99  	// Start Kong.
   100  	if !isContainerExcluded(utils.KongImage, excluded) {
   101  		var kongConfigBuf bytes.Buffer
   102  		if err := kongConfigTemplate.Execute(&kongConfigBuf, struct{ ProjectId, AnonKey, ServiceRoleKey string }{
   103  			ProjectId:      utils.Config.ProjectId,
   104  			AnonKey:        utils.AnonKey,
   105  			ServiceRoleKey: utils.ServiceRoleKey,
   106  		}); err != nil {
   107  			return err
   108  		}
   109  
   110  		if _, err := utils.DockerStart(
   111  			ctx,
   112  			container.Config{
   113  				Image: utils.KongImage,
   114  				Env: []string{
   115  					"KONG_DATABASE=off",
   116  					"KONG_DECLARATIVE_CONFIG=/home/kong/kong.yml",
   117  					"KONG_DNS_ORDER=LAST,A,CNAME", // https://github.com/Redstoneguy129/cli/issues/14
   118  					"KONG_PLUGINS=request-transformer,cors,key-auth",
   119  					// Need to increase the nginx buffers in kong to avoid it rejecting the rather
   120  					// sizeable response headers azure can generate
   121  					// Ref: https://github.com/Kong/kong/issues/3974#issuecomment-482105126
   122  					"KONG_NGINX_PROXY_PROXY_BUFFER_SIZE=160k",
   123  					"KONG_NGINX_PROXY_PROXY_BUFFERS=64 160k",
   124  				},
   125  				Entrypoint: []string{"sh", "-c", `cat <<'EOF' > /home/kong/kong.yml && ./docker-entrypoint.sh kong docker-start
   126  ` + kongConfigBuf.String() + `
   127  EOF
   128  `},
   129  			},
   130  			container.HostConfig{
   131  				PortBindings:  nat.PortMap{"8000/tcp": []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Api.Port), 10)}}},
   132  				RestartPolicy: container.RestartPolicy{Name: "always"},
   133  			},
   134  			utils.KongId,
   135  		); err != nil {
   136  			return err
   137  		}
   138  		started = append(started, utils.KongId)
   139  	}
   140  
   141  	// Start GoTrue.
   142  	if !isContainerExcluded(utils.GotrueImage, excluded) {
   143  		env := []string{
   144  			fmt.Sprintf("API_EXTERNAL_URL=http://"+utils.Config.Hostname+":%v", utils.Config.Api.Port),
   145  
   146  			"GOTRUE_API_HOST=0.0.0.0",
   147  			"GOTRUE_API_PORT=9999",
   148  
   149  			"GOTRUE_DB_DRIVER=postgres",
   150  			"GOTRUE_DB_DATABASE_URL=postgresql://supabase_auth_admin:postgres@" + utils.DbId + ":5432/postgres",
   151  
   152  			"GOTRUE_SITE_URL=" + utils.Config.Auth.SiteUrl,
   153  			"GOTRUE_URI_ALLOW_LIST=" + strings.Join(utils.Config.Auth.AdditionalRedirectUrls, ","),
   154  			fmt.Sprintf("GOTRUE_DISABLE_SIGNUP=%v", !*utils.Config.Auth.EnableSignup),
   155  
   156  			"GOTRUE_JWT_ADMIN_ROLES=service_role",
   157  			"GOTRUE_JWT_AUD=authenticated",
   158  			"GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated",
   159  			fmt.Sprintf("GOTRUE_JWT_EXP=%v", utils.Config.Auth.JwtExpiry),
   160  			"GOTRUE_JWT_SECRET=" + utils.JWTSecret,
   161  
   162  			fmt.Sprintf("GOTRUE_EXTERNAL_EMAIL_ENABLED=%v", *utils.Config.Auth.Email.EnableSignup),
   163  			fmt.Sprintf("GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=%v", *utils.Config.Auth.Email.DoubleConfirmChanges),
   164  			fmt.Sprintf("GOTRUE_MAILER_AUTOCONFIRM=%v", !*utils.Config.Auth.Email.EnableConfirmations),
   165  
   166  			"GOTRUE_SMTP_HOST=" + utils.InbucketId,
   167  			"GOTRUE_SMTP_PORT=2500",
   168  			"GOTRUE_SMTP_ADMIN_EMAIL=admin@email.com",
   169  			"GOTRUE_SMTP_MAX_FREQUENCY=1s",
   170  			"GOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verify",
   171  			"GOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify",
   172  			"GOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verify",
   173  			"GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify",
   174  			"GOTRUE_RATE_LIMIT_EMAIL_SENT=360000",
   175  
   176  			"GOTRUE_EXTERNAL_PHONE_ENABLED=true",
   177  			"GOTRUE_SMS_AUTOCONFIRM=true",
   178  
   179  			"GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=false",
   180  		}
   181  
   182  		for name, config := range utils.Config.Auth.External {
   183  			env = append(
   184  				env,
   185  				fmt.Sprintf("GOTRUE_EXTERNAL_%s_ENABLED=%v", strings.ToUpper(name), config.Enabled),
   186  				fmt.Sprintf("GOTRUE_EXTERNAL_%s_CLIENT_ID=%s", strings.ToUpper(name), config.ClientId),
   187  				fmt.Sprintf("GOTRUE_EXTERNAL_%s_SECRET=%s", strings.ToUpper(name), config.Secret),
   188  			)
   189  
   190  			if config.RedirectUri != "" {
   191  				env = append(env,
   192  					fmt.Sprintf("GOTRUE_EXTERNAL_%s_REDIRECT_URI=%s", strings.ToUpper(name), config.RedirectUri),
   193  				)
   194  			} else {
   195  				env = append(env,
   196  					fmt.Sprintf("GOTRUE_EXTERNAL_%s_REDIRECT_URI=http://"+utils.Config.Hostname+":%v/auth/v1/callback", strings.ToUpper(name), utils.Config.Api.Port),
   197  				)
   198  			}
   199  
   200  			if config.Url != "" {
   201  				env = append(env,
   202  					fmt.Sprintf("GOTRUE_EXTERNAL_%s_URL=%s", strings.ToUpper(name), config.Url),
   203  				)
   204  			}
   205  		}
   206  
   207  		if _, err := utils.DockerStart(
   208  			ctx,
   209  			container.Config{
   210  				Image: utils.GotrueImage,
   211  				Env:   env,
   212  				Healthcheck: &container.HealthConfig{
   213  					Test:     []string{"CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://\"+utils.Config.Hostname+\":9999/health"},
   214  					Interval: 2 * time.Second,
   215  					Timeout:  2 * time.Second,
   216  					Retries:  10,
   217  				},
   218  			},
   219  			container.HostConfig{
   220  				RestartPolicy: container.RestartPolicy{Name: "always"},
   221  			},
   222  			utils.GotrueId,
   223  		); err != nil {
   224  			return err
   225  		}
   226  		started = append(started, utils.GotrueId)
   227  	}
   228  
   229  	// Start Inbucket.
   230  	if !isContainerExcluded(utils.InbucketImage, excluded) {
   231  		inbucketPortBindings := nat.PortMap{"9000/tcp": []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.Port), 10)}}}
   232  		if utils.Config.Inbucket.SmtpPort != 0 {
   233  			inbucketPortBindings["2500/tcp"] = []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.SmtpPort), 10)}}
   234  		}
   235  		if utils.Config.Inbucket.Pop3Port != 0 {
   236  			inbucketPortBindings["1100/tcp"] = []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Inbucket.Pop3Port), 10)}}
   237  		}
   238  		if _, err := utils.DockerStart(
   239  			ctx,
   240  			container.Config{
   241  				Image: utils.InbucketImage,
   242  			},
   243  			container.HostConfig{
   244  				PortBindings:  inbucketPortBindings,
   245  				RestartPolicy: container.RestartPolicy{Name: "always"},
   246  			},
   247  			utils.InbucketId,
   248  		); err != nil {
   249  			return err
   250  		}
   251  		started = append(started, utils.InbucketId)
   252  	}
   253  
   254  	// Start Realtime.
   255  	if !isContainerExcluded(utils.RealtimeImage, excluded) {
   256  		if _, err := utils.DockerStart(
   257  			ctx,
   258  			container.Config{
   259  				Image: utils.RealtimeImage,
   260  				Env: []string{
   261  					"PORT=4000",
   262  					"DB_HOST=" + utils.DbId,
   263  					"DB_PORT=5432",
   264  					"DB_USER=postgres",
   265  					"DB_PASSWORD=postgres",
   266  					"DB_NAME=postgres",
   267  					"DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime",
   268  					"DB_ENC_KEY=supabaserealtime",
   269  					"API_JWT_SECRET=" + utils.JWTSecret,
   270  					"FLY_ALLOC_ID=abc123",
   271  					"FLY_APP_NAME=realtime",
   272  					"SECRET_KEY_BASE=EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
   273  					"ERL_AFLAGS=-proto_dist inet_tcp",
   274  					"ENABLE_TAILSCALE=false",
   275  					"DNS_NODES=''",
   276  				},
   277  				Cmd: []string{
   278  					"/bin/sh", "-c",
   279  					"/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server",
   280  				},
   281  				Healthcheck: &container.HealthConfig{
   282  					Test:     []string{"CMD", "bash", "-c", "printf \\0 > /dev/tcp/\"+utils.Config.Hostname+\"/4000"},
   283  					Interval: 2 * time.Second,
   284  					Timeout:  2 * time.Second,
   285  					Retries:  10,
   286  				},
   287  			},
   288  			container.HostConfig{
   289  				RestartPolicy: container.RestartPolicy{Name: "always"},
   290  			},
   291  			utils.RealtimeId,
   292  		); err != nil {
   293  			return err
   294  		}
   295  		started = append(started, utils.RealtimeId)
   296  	}
   297  
   298  	// Start PostgREST.
   299  	if !isContainerExcluded(utils.PostgrestImage, excluded) {
   300  		if _, err := utils.DockerStart(
   301  			ctx,
   302  			container.Config{
   303  				Image: utils.PostgrestImage,
   304  				Env: []string{
   305  					"PGRST_DB_URI=postgresql://authenticator:postgres@" + utils.DbId + ":5432/postgres",
   306  					"PGRST_DB_SCHEMAS=" + strings.Join(utils.Config.Api.Schemas, ","),
   307  					"PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","),
   308  					"PGRST_DB_ANON_ROLE=anon",
   309  					"PGRST_JWT_SECRET=" + utils.JWTSecret,
   310  				},
   311  				// PostgREST does not expose a shell for health check
   312  			},
   313  			container.HostConfig{
   314  				RestartPolicy: container.RestartPolicy{Name: "always"},
   315  			},
   316  			utils.RestId,
   317  		); err != nil {
   318  			return err
   319  		}
   320  		started = append(started, utils.RestId)
   321  	}
   322  
   323  	// Start Storage.
   324  	if !isContainerExcluded(utils.StorageImage, excluded) {
   325  		if _, err := utils.DockerStart(
   326  			ctx,
   327  			container.Config{
   328  				Image: utils.StorageImage,
   329  				Env: []string{
   330  					"ANON_KEY=" + utils.AnonKey,
   331  					"SERVICE_KEY=" + utils.ServiceRoleKey,
   332  					"POSTGREST_URL=http://" + utils.RestId + ":3000",
   333  					"PGRST_JWT_SECRET=" + utils.JWTSecret,
   334  					"DATABASE_URL=postgresql://supabase_storage_admin:postgres@" + utils.DbId + ":5432/postgres",
   335  					fmt.Sprintf("FILE_SIZE_LIMIT=%v", utils.Config.Storage.FileSizeLimit),
   336  					"STORAGE_BACKEND=file",
   337  					"FILE_STORAGE_BACKEND_PATH=/var/lib/storage",
   338  					"TENANT_ID=stub",
   339  					// TODO: https://github.com/supabase/storage-api/issues/55
   340  					"REGION=stub",
   341  					"GLOBAL_S3_BUCKET=stub",
   342  					"ENABLE_IMAGE_TRANSFORMATION=true",
   343  					"IMGPROXY_URL=http://" + utils.ImgProxyId + ":5001",
   344  				},
   345  				Healthcheck: &container.HealthConfig{
   346  					Test:     []string{"CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://" + utils.Config.Hostname + ":5000/status"},
   347  					Interval: 2 * time.Second,
   348  					Timeout:  2 * time.Second,
   349  					Retries:  10,
   350  				},
   351  			},
   352  			container.HostConfig{
   353  				RestartPolicy: container.RestartPolicy{Name: "always"},
   354  				Binds:         []string{utils.StorageId + ":/var/lib/storage"},
   355  			},
   356  			utils.StorageId,
   357  		); err != nil {
   358  			return err
   359  		}
   360  		started = append(started, utils.StorageId)
   361  	}
   362  
   363  	// Start Storage ImgProxy.
   364  	if !isContainerExcluded(utils.ImageProxyImage, excluded) {
   365  		if _, err := utils.DockerStart(
   366  			ctx,
   367  			container.Config{
   368  				Image: utils.ImageProxyImage,
   369  				Env: []string{
   370  					"IMGPROXY_BIND=:5001",
   371  					"IMGPROXY_LOCAL_FILESYSTEM_ROOT=/",
   372  					"IMGPROXY_USE_ETAG=/",
   373  				},
   374  				Healthcheck: &container.HealthConfig{
   375  					Test:     []string{"CMD", "imgproxy", "health"},
   376  					Interval: 2 * time.Second,
   377  					Timeout:  2 * time.Second,
   378  					Retries:  10,
   379  				},
   380  			},
   381  			container.HostConfig{
   382  				VolumesFrom:   []string{utils.StorageId},
   383  				RestartPolicy: container.RestartPolicy{Name: "always"},
   384  			},
   385  			utils.ImgProxyId,
   386  		); err != nil {
   387  			return err
   388  		}
   389  		started = append(started, utils.ImgProxyId)
   390  	}
   391  
   392  	// Start pg-meta.
   393  	if !isContainerExcluded(utils.PgmetaImage, excluded) {
   394  		if _, err := utils.DockerStart(
   395  			ctx,
   396  			container.Config{
   397  				Image: utils.PgmetaImage,
   398  				Env: []string{
   399  					"PG_META_PORT=8080",
   400  					"PG_META_DB_HOST=" + utils.DbId,
   401  				},
   402  				Healthcheck: &container.HealthConfig{
   403  					Test:     []string{"CMD", "node", "-e", "require('http').get('http://'+utils.Config.Hostname+':8080/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"},
   404  					Interval: 2 * time.Second,
   405  					Timeout:  2 * time.Second,
   406  					Retries:  10,
   407  				},
   408  			},
   409  			container.HostConfig{
   410  				RestartPolicy: container.RestartPolicy{Name: "always"},
   411  			},
   412  			utils.PgmetaId,
   413  		); err != nil {
   414  			return err
   415  		}
   416  		started = append(started, utils.PgmetaId)
   417  	}
   418  
   419  	// Start Studio.
   420  	if !isContainerExcluded(utils.StudioImage, excluded) {
   421  		if _, err := utils.DockerStart(
   422  			ctx,
   423  			container.Config{
   424  				Image: utils.StudioImage,
   425  				Env: []string{
   426  					"STUDIO_PG_META_URL=http://" + utils.PgmetaId + ":8080",
   427  					"POSTGRES_PASSWORD=postgres",
   428  					"SUPABASE_URL=http://" + utils.KongId + ":8000",
   429  					fmt.Sprintf("SUPABASE_REST_URL=http://"+utils.Config.Hostname+":%v/rest/v1/", utils.Config.Api.Port),
   430  					fmt.Sprintf("SUPABASE_PUBLIC_URL=http://"+utils.Config.Hostname+":%v/", utils.Config.Api.Port),
   431  					"SUPABASE_ANON_KEY=" + utils.AnonKey,
   432  					"SUPABASE_SERVICE_KEY=" + utils.ServiceRoleKey,
   433  				},
   434  				Healthcheck: &container.HealthConfig{
   435  					Test:     []string{"CMD", "node", "-e", "require('http').get('http:/'+utils.Config.Hostname+':3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"},
   436  					Interval: 2 * time.Second,
   437  					Timeout:  2 * time.Second,
   438  					Retries:  10,
   439  				},
   440  			},
   441  			container.HostConfig{
   442  				PortBindings:  nat.PortMap{"3000/tcp": []nat.PortBinding{{HostPort: strconv.FormatUint(uint64(utils.Config.Studio.Port), 10)}}},
   443  				RestartPolicy: container.RestartPolicy{Name: "always"},
   444  			},
   445  			utils.StudioId,
   446  		); err != nil {
   447  			return err
   448  		}
   449  		started = append(started, utils.StudioId)
   450  	}
   451  
   452  	return waitForServiceReady(ctx, started)
   453  }
   454  
   455  func isContainerExcluded(imageName string, excluded map[string]bool) bool {
   456  	short := utils.ShortContainerImageName(imageName)
   457  	if val, ok := excluded[short]; ok && val {
   458  		return true
   459  	}
   460  	return false
   461  }
   462  
   463  func ExcludableContainers() []string {
   464  	names := []string{}
   465  	for _, image := range utils.ServiceImages {
   466  		names = append(names, utils.ShortContainerImageName(image))
   467  	}
   468  	return names
   469  }
   470  
   471  func waitForServiceReady(ctx context.Context, started []string) error {
   472  	probe := func() bool {
   473  		var unhealthy []string
   474  		for _, container := range started {
   475  			if !status.IsServiceReady(ctx, container) {
   476  				unhealthy = append(unhealthy, container)
   477  			}
   478  		}
   479  		started = unhealthy
   480  		return len(started) == 0
   481  	}
   482  	if !reset.RetryEverySecond(ctx, probe, 20*time.Second) {
   483  		return fmt.Errorf("%w: %v", errUnhealthy, started)
   484  	}
   485  	return nil
   486  }