Files
nhost/cli/cmd/dev/up.go
2025-11-13 10:07:25 +01:00

580 lines
15 KiB
Go

package dev
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"text/tabwriter"
"time"
"github.com/nhost/be/services/mimir/model"
"github.com/nhost/nhost/cli/clienv"
"github.com/nhost/nhost/cli/cmd/config"
"github.com/nhost/nhost/cli/cmd/run"
"github.com/nhost/nhost/cli/cmd/software"
"github.com/nhost/nhost/cli/dockercompose"
"github.com/nhost/nhost/cli/project/env"
"github.com/urfave/cli/v3"
)
func deptr[T any](t *T) T { //nolint:ireturn
if t == nil {
return *new(T)
}
return *t
}
const (
flagHTTPPort = "http-port"
flagDisableTLS = "disable-tls"
flagPostgresPort = "postgres-port"
flagApplySeeds = "apply-seeds"
flagAuthPort = "auth-port"
flagStoragePort = "storage-port"
flagsFunctionsPort = "functions-port"
flagsHasuraPort = "hasura-port"
flagsHasuraConsolePort = "hasura-console-port"
flagDashboardVersion = "dashboard-version"
flagConfigserverImage = "configserver-image"
flagRunService = "run-service"
flagDownOnError = "down-on-error"
flagCACertificates = "ca-certificates"
)
const (
defaultHTTPPort = 443
defaultPostgresPort = 5432
)
func CommandUp() *cli.Command { //nolint:funlen
return &cli.Command{ //nolint:exhaustruct
Name: "up",
Aliases: []string{},
Usage: "Start local development environment",
Action: commandUp,
Flags: []cli.Flag{
&cli.UintFlag{ //nolint:exhaustruct
Name: flagHTTPPort,
Usage: "HTTP port to listen on",
Value: defaultHTTPPort,
Sources: cli.EnvVars("NHOST_HTTP_PORT"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagDisableTLS,
Usage: "Disable TLS",
Value: false,
Sources: cli.EnvVars("NHOST_DISABLE_TLS"),
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagPostgresPort,
Usage: "Postgres port to listen on",
Value: defaultPostgresPort,
Sources: cli.EnvVars("NHOST_POSTGRES_PORT"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagApplySeeds,
Usage: "Apply seeds. If the .nhost folder does not exist, seeds will be applied regardless of this flag",
Value: false,
Sources: cli.EnvVars("NHOST_APPLY_SEEDS"),
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagAuthPort,
Usage: "If specified, expose auth on this port. Not recommended",
Value: 0,
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagStoragePort,
Usage: "If specified, expose storage on this port. Not recommended",
Value: 0,
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagsFunctionsPort,
Usage: "If specified, expose functions on this port. Not recommended",
Value: 0,
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagsHasuraPort,
Usage: "If specified, expose hasura on this port. Not recommended",
Value: 0,
},
&cli.UintFlag{ //nolint:exhaustruct
Name: flagsHasuraConsolePort,
Usage: "If specified, expose hasura console on this port. Not recommended",
Value: 0,
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.42.0",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagConfigserverImage,
Hidden: true,
Value: "",
Sources: cli.EnvVars("NHOST_CONFIGSERVER_IMAGE"),
},
&cli.StringSliceFlag{ //nolint:exhaustruct
Name: flagRunService,
Usage: "Run service to add to the development environment. Can be passed multiple times. Comma-separated values are also accepted. Format: /path/to/run-service.toml[:overlay_name]", //nolint:lll
Sources: cli.EnvVars("NHOST_RUN_SERVICE"),
},
&cli.BoolFlag{ //nolint:exhaustruct
Name: flagDownOnError,
Usage: "Skip confirmation",
Sources: cli.EnvVars("NHOST_YES"),
},
&cli.StringFlag{ //nolint:exhaustruct
Name: flagCACertificates,
Usage: "Mounts and everrides path to CA certificates in the containers",
Sources: cli.EnvVars("NHOST_CA_CERTIFICATES"),
},
},
Commands: []*cli.Command{
CommandCloud(),
},
}
}
func commandUp(ctx context.Context, cmd *cli.Command) error {
ce := clienv.FromCLI(cmd)
// projname to be root directory
if !clienv.PathExists(ce.Path.NhostToml()) {
return errors.New( //nolint:err113
"no nhost project found, please run `nhost init` or `nhost config pull`",
)
}
if !clienv.PathExists(ce.Path.Secrets()) {
return errors.New( //nolint:err113
"no secrets found, please run `nhost init` or `nhost config pull`",
)
}
configserverImage := cmd.String(flagConfigserverImage)
if configserverImage == "" {
configserverImage = "nhost/cli:" + cmd.Root().Version
}
applySeeds := cmd.Bool(flagApplySeeds) || !clienv.PathExists(ce.Path.DotNhostFolder())
return Up(
ctx,
ce,
cmd.Root().Version,
cmd.Uint(flagHTTPPort),
!cmd.Bool(flagDisableTLS),
cmd.Uint(flagPostgresPort),
applySeeds,
dockercompose.ExposePorts{
Auth: cmd.Uint(flagAuthPort),
Storage: cmd.Uint(flagStoragePort),
Graphql: cmd.Uint(flagsHasuraPort),
Console: cmd.Uint(flagsHasuraConsolePort),
Functions: cmd.Uint(flagsFunctionsPort),
},
cmd.String(flagDashboardVersion),
configserverImage,
cmd.String(flagCACertificates),
cmd.StringSlice(flagRunService),
cmd.Bool(flagDownOnError),
)
}
func migrations(
ctx context.Context,
ce *clienv.CliEnv,
dc *dockercompose.DockerCompose,
endpoint string,
applySeeds bool,
) error {
if clienv.PathExists(filepath.Join(ce.Path.NhostFolder(), "migrations", "default")) {
ce.Infoln("Applying migrations...")
if err := dc.ApplyMigrations(ctx, endpoint); err != nil {
return fmt.Errorf("failed to apply migrations: %w", err)
}
} else {
ce.Warnln("No migrations found, make sure this is intentional or it could lead to unexpected behavior")
}
if clienv.PathExists(filepath.Join(ce.Path.NhostFolder(), "metadata", "version.yaml")) {
ce.Infoln("Applying metadata...")
if err := dc.ApplyMetadata(ctx, endpoint); err != nil {
return fmt.Errorf("failed to apply metadata: %w", err)
}
} else {
ce.Warnln("No metadata found, make sure this is intentional or it could lead to unexpected behavior")
}
if applySeeds {
if clienv.PathExists(filepath.Join(ce.Path.NhostFolder(), "seeds", "default")) {
ce.Infoln("Applying seeds...")
if err := dc.ApplySeeds(ctx, endpoint); err != nil {
return fmt.Errorf("failed to apply seeds: %w", err)
}
}
}
return nil
}
func restart(
ctx context.Context,
ce *clienv.CliEnv,
dc *dockercompose.DockerCompose,
composeFile *dockercompose.ComposeFile,
) error {
ce.Infoln("Restarting services to reapply metadata if needed...")
args := []string{"restart"}
if _, ok := composeFile.Services["storage"]; ok {
args = append(args, "storage")
}
if _, ok := composeFile.Services["auth"]; ok {
args = append(args, "auth")
}
if _, ok := composeFile.Services["ai"]; ok {
args = append(args, "ai")
}
if _, ok := composeFile.Services["functions"]; ok {
args = append(args, "functions")
}
if err := dc.Wrapper(ctx, args...); err != nil {
return fmt.Errorf("failed to restart services: %w", err)
}
ce.Infoln("Verifying services are healthy...")
// this ensures that all services are healthy before returning
if err := dc.Start(ctx); err != nil {
return fmt.Errorf("failed to wait services: %w", err)
}
return nil
}
func reload(
ctx context.Context,
ce *clienv.CliEnv,
dc *dockercompose.DockerCompose,
) error {
ce.Infoln("Reapplying metadata...")
if err := dc.ReloadMetadata(ctx); err != nil {
return fmt.Errorf("failed to reapply metadata: %w", err)
}
return nil
}
func parseRunServiceConfigFlag(value string) (string, string, error) {
parts := strings.Split(value, ":")
switch len(parts) {
case 1:
return parts[0], "", nil
case 2: //nolint:mnd
return parts[0], parts[1], nil
default:
return "", "", fmt.Errorf( //nolint:err113
"invalid run service format, must be /path/to/config.toml:overlay_name, got %s",
value,
)
}
}
func processRunServices(
ce *clienv.CliEnv,
runServices []string,
secrets model.Secrets,
) ([]*dockercompose.RunService, error) {
r := make([]*dockercompose.RunService, 0, len(runServices))
for _, runService := range runServices {
cfgPath, overlayName, err := parseRunServiceConfigFlag(runService)
if err != nil {
return nil, err
}
cfg, err := run.Validate(ce, cfgPath, overlayName, secrets, false)
if err != nil {
return nil, fmt.Errorf("failed to validate run service %s: %w", cfgPath, err)
}
r = append(r, &dockercompose.RunService{
Path: cfgPath,
Config: cfg,
})
}
return r, nil
}
func up( //nolint:funlen,cyclop
ctx context.Context,
ce *clienv.CliEnv,
appVersion string,
dc *dockercompose.DockerCompose,
httpPort uint,
useTLS bool,
postgresPort uint,
applySeeds bool,
ports dockercompose.ExposePorts,
dashboardVersion string,
configserverImage string,
caCertificatesPath string,
runServices []string,
) error {
ctx, cancel := context.WithCancel(ctx)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
go func() {
<-sigChan
cancel()
}()
var secrets model.Secrets
if err := clienv.UnmarshalFile(ce.Path.Secrets(), &secrets, env.Unmarshal); err != nil {
return fmt.Errorf(
"failed to parse secrets, make sure secret values are between quotes: %w",
err,
)
}
cfg, err := config.Validate(ce, "local", secrets)
if err != nil {
return fmt.Errorf("failed to validate config: %w", err)
}
ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) //nolint:mnd
defer cancel()
ce.Infoln("Checking versions...")
if err := software.CheckVersions(ctxWithTimeout, ce, cfg, appVersion); err != nil {
ce.Warnln("Problem verifying recommended versions: %s", err.Error())
}
runServicesCfg, err := processRunServices(ce, runServices, secrets)
if err != nil {
return err
}
ce.Infoln("Setting up Nhost development environment...")
composeFile, err := dockercompose.ComposeFileFromConfig(
cfg,
ce.LocalSubdomain(),
ce.ProjectName(),
httpPort,
useTLS,
postgresPort,
ce.Path.NhostFolder(),
ce.Path.DotNhostFolder(),
ce.Path.Root(),
ports,
ce.Branch(),
dashboardVersion,
configserverImage,
clienv.PathExists(ce.Path.Functions()),
caCertificatesPath,
runServicesCfg...,
)
if err != nil {
return fmt.Errorf("failed to generate docker-compose.yaml: %w", err)
}
if err := dc.WriteComposeFile(composeFile); err != nil {
return fmt.Errorf("failed to write docker-compose.yaml: %w", err)
}
ce.Infoln("Starting Nhost development environment...")
if err = dc.Start(ctx); err != nil {
return fmt.Errorf("failed to start Nhost development environment: %w", err)
}
if err := migrations(ctx, ce, dc, "http://graphql:8080", applySeeds); err != nil {
return err
}
if err := restart(ctx, ce, dc, composeFile); err != nil {
return err
}
docker := dockercompose.NewDocker()
ce.Infoln("Downloading metadata...")
if err := docker.HasuraWrapper(
ctx,
ce.LocalSubdomain(),
ce.Path.NhostFolder(),
*cfg.Hasura.Version,
"metadata", "export",
"--skip-update-check",
"--log-level", "ERROR",
"--endpoint", dockercompose.URL(ce.LocalSubdomain(), "hasura", httpPort, useTLS),
"--admin-secret", cfg.Hasura.AdminSecret,
); err != nil {
return fmt.Errorf("failed to create metadata: %w", err)
}
if err := reload(ctx, ce, dc); err != nil {
return err
}
ce.Infoln("Nhost development environment started.")
printInfo(ce.LocalSubdomain(), httpPort, postgresPort, useTLS, runServicesCfg)
return nil
}
func printInfo(
subdomain string,
httpPort, postgresPort uint,
useTLS bool,
runServices []*dockercompose.RunService,
) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) //nolint:mnd
fmt.Fprintf(w, "URLs:\n")
fmt.Fprintf(w,
"- Postgres:\t\tpostgres://postgres:postgres@localhost:%d/local\n",
postgresPort,
)
fmt.Fprintf(w, "- Hasura:\t\t%s\n", dockercompose.URL(
subdomain, "hasura", httpPort, useTLS))
fmt.Fprintf(w, "- GraphQL:\t\t%s\n", dockercompose.URL(
subdomain, "graphql", httpPort, useTLS))
fmt.Fprintf(w, "- Auth:\t\t%s\n", dockercompose.URL(
subdomain, "auth", httpPort, useTLS))
fmt.Fprintf(w, "- Storage:\t\t%s\n", dockercompose.URL(
subdomain, "storage", httpPort, useTLS))
fmt.Fprintf(w, "- Functions:\t\t%s\n", dockercompose.URL(
subdomain, "functions", httpPort, useTLS))
fmt.Fprintf(w, "- Dashboard:\t\t%s\n", dockercompose.URL(
subdomain, "dashboard", httpPort, useTLS))
fmt.Fprintf(w, "- Mailhog:\t\t%s\n", dockercompose.URL(
subdomain, "mailhog", httpPort, useTLS))
for _, svc := range runServices {
for _, port := range svc.Config.GetPorts() {
if deptr(port.GetPublish()) {
fmt.Fprintf(
w,
"- run-%s:\t\tFrom laptop:\t%s://localhost:%d\n",
svc.Config.Name,
port.GetType(),
port.GetPort(),
)
fmt.Fprintf(
w,
"\t\tFrom services:\t%s://run-%s:%d\n",
port.GetType(),
svc.Config.Name,
port.GetPort(),
)
}
}
}
fmt.Fprintf(w, "\n")
fmt.Fprintf(w, "SDK Configuration:\n")
fmt.Fprintf(w, " Subdomain:\t%s\n", subdomain)
fmt.Fprintf(w, " Region:\tlocal\n")
fmt.Fprintf(w, "")
fmt.Fprintf(w, "Run `nhost up` to reload the development environment\n")
fmt.Fprintf(w, "Run `nhost down` to stop the development environment\n")
fmt.Fprintf(w, "Run `nhost logs` to watch the logs\n")
w.Flush()
}
func upErr(
ce *clienv.CliEnv,
dc *dockercompose.DockerCompose,
downOnError bool,
err error,
) error {
ce.Warnln("%s", err.Error())
if !downOnError {
ce.PromptMessage("Do you want to stop Nhost's development environment? [y/N] ")
resp, err := ce.PromptInput(false)
if err != nil {
ce.Warnln("failed to read input: %s", err)
return nil
}
if resp != "y" && resp != "Y" {
return nil
}
}
ce.Infoln("Stopping Nhost development environment...")
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := dc.Stop(ctx, false); err != nil {
ce.Warnln("failed to stop Nhost development environment: %s", err)
}
return err
}
func Up(
ctx context.Context,
ce *clienv.CliEnv,
appVersion string,
httpPort uint,
useTLS bool,
postgresPort uint,
applySeeds bool,
ports dockercompose.ExposePorts,
dashboardVersion string,
configserverImage string,
caCertificatesPath string,
runServices []string,
downOnError bool,
) error {
dc := dockercompose.New(ce.Path.WorkingDir(), ce.Path.DockerCompose(), ce.ProjectName())
if err := up(
ctx,
ce,
appVersion,
dc,
httpPort,
useTLS,
postgresPort,
applySeeds,
ports,
dashboardVersion,
configserverImage,
caCertificatesPath,
runServices,
); err != nil {
return upErr(ce, dc, downOnError, err) //nolint:contextcheck
}
return nil
}