Ctrl K
ComunidadGuías…

cli/dockercompose/compose.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package dockercompose

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"

	"github.com/nhost/be/services/mimir/model"
	"github.com/nhost/nhost/cli/ssl"
)

const (
	authPort         = 4000
	mailhogPort      = 8025
	dashboardPort    = 3000
	storagePort      = 5000
	functionsPort    = 3000
	hasuraPort       = 8080
	consolePort      = 9695
	postgresPort     = 5432
	configserverPort = 8088
)

const (
	minimumHasuraVerson = "v2.18.0"
)

func rootNodeModules(branch string) string {
	return sanitizeBranch(branch) + "-root_node_modules"
}

func functionsNodeModules(branch string) string {
	return sanitizeBranch(branch) + "-functions_node_modules"
}

func ports(host, container uint) []Port {
	if host == 0 {
		return nil
	}

	return []Port{
		{
			Mode:      "ingress",
			Target:    container,
			Published: strconv.FormatUint(uint64(host), 10),
			Protocol:  "tcp",
		},
	}
}

type ComposeFile struct {
	Services map[string]*Service `yaml:"services"`
	Volumes  map[string]struct{} `yaml:"volumes"`
}

// Environment is a map of environment variables that escapes literal `$` as
// `$$` when marshaled to YAML so Docker Compose doesn't interpret them as
// variable substitution.
type Environment map[string]string

func (e Environment) MarshalYAML() (any, error) {
	escaped := make(map[string]string, len(e))
	for k, v := range e {
		escaped[k] = strings.ReplaceAll(v, "$", "$$")
	}

	return escaped, nil
}

//nolint:tagliatelle
type Service struct {
	Image       string                    `yaml:"image"`
	DependsOn   map[string]DependsOn      `yaml:"depends_on,omitempty"`
	EntryPoint  []string                  `yaml:"entrypoint,omitempty"`
	Command     []string                  `yaml:"command,omitempty"`
	Environment Environment               `yaml:"environment,omitempty"`
	ExtraHosts  []string                  `yaml:"extra_hosts"`
	HealthCheck *HealthCheck              `yaml:"healthcheck,omitempty"`
	Labels      map[string]string         `yaml:"labels,omitempty"`
	Networks    map[string]*NetworkConfig `yaml:"networks,omitempty"`
	Ports       []Port                    `yaml:"ports,omitempty"`
	Restart     string                    `yaml:"restart"`
	User        *string                   `yaml:"user,omitempty"`
	Volumes     []Volume                  `yaml:"volumes,omitempty"`
	WorkingDir  *string                   `yaml:"working_dir,omitempty"`
}

type DependsOn struct {
	Condition string `yaml:"condition"`
}

type NetworkConfig struct {
	Aliases []string `yaml:"aliases,omitempty"`
}

func networkAliases(aliases ...string) map[string]*NetworkConfig {
	return map[string]*NetworkConfig{
		"default": {Aliases: aliases},
	}
}

//nolint:tagliatelle
type HealthCheck struct {
	Test        []string `yaml:"test"`
	Timeout     string   `yaml:"timeout"`
	Interval    string   `yaml:"interval"`
	StartPeriod string   `yaml:"start_period"`
}

type Port struct {
	Mode      string `yaml:"mode"`
	Target    uint   `yaml:"target"`
	Published string `yaml:"published"`
	Protocol  string `yaml:"protocol"`
}

//nolint:tagliatelle,tagalign // golines tag order conflicts with golangci-lint tagalign
type Volume struct {
	Type     string       `yaml:"type"`
	Source   string       `yaml:"source"`
	Target   string       `yaml:"target"`
	ReadOnly *bool        `yaml:"read_only,omitempty"`
	Bind     *BindOptions `yaml:"bind,omitempty"      exhaustruct:"optional"`
}

// BindOptions holds bind-mount-specific settings. SELinux carries the
// relabel option ("z" for a shared label) so that on SELinux or Podman
// hosts the container is granted access to the host source without the
// user having to relax SELinux enforcement themselves.
type BindOptions struct {
	SELinux string `yaml:"selinux,omitempty"`
}

// extraHosts is the set of /etc/hosts entries injected into every bridge
// service. Public local.nhost.run hostnames are intentionally absent: on
// Linux, `host-gateway` resolves to the default docker0 bridge gateway,
// which is unroutable from containers attached to the user-defined
// project bridge. Resolution for those hostnames is provided by network
// aliases on the traefik service (see traefikAliases).
var extraHosts = []string{ //nolint:gochecknoglobals // immutable /etc/hosts entries shared by all bridge services
	"host.docker.internal:host-gateway",
}

// traefikAliases returns the set of public local hostnames that resolve
// to the traefik container via Docker's embedded DNS on the project
// bridge. Container-to-container HTTPS via these hostnames terminates at
// traefik and is routed using the existing ingress labels.
func traefikAliases(subdomain string) []string {
	return []string{
		subdomain + ".auth.local.nhost.run",
		subdomain + ".db.local.nhost.run",
		subdomain + ".functions.local.nhost.run",
		subdomain + ".graphql.local.nhost.run",
		subdomain + ".hasura.local.nhost.run",
		subdomain + ".storage.local.nhost.run",
		subdomain + ".dashboard.local.nhost.run",
		subdomain + ".mailhog.local.nhost.run",
		"local.auth.nhost.run",
		"local.db.nhost.run",
		"local.functions.nhost.run",
		"local.graphql.nhost.run",
		"local.hasura.nhost.run",
		"local.storage.nhost.run",
		"local.dashboard.nhost.run",
		"local.mailhog.nhost.run",
	}
}

// hostGatewayHosts returns the legacy host-gateway based mapping used by
// containers that run outside the project's bridge network (e.g. the
// standalone hasura-cli helper started via `docker run` without
// --network), where the default docker0 bridge gateway can reach the
// host-published traefik port.
func hostGatewayHosts(subdomain string) []string {
	return []string{
		"host.docker.internal:host-gateway",
		subdomain + ".auth.local.nhost.run:host-gateway",
		subdomain + ".db.local.nhost.run:host-gateway",
		subdomain + ".functions.local.nhost.run:host-gateway",
		subdomain + ".graphql.local.nhost.run:host-gateway",
		subdomain + ".hasura.local.nhost.run:host-gateway",
		subdomain + ".storage.local.nhost.run:host-gateway",
		"local.auth.nhost.run:host-gateway",
		"local.db.nhost.run:host-gateway",
		"local.functions.nhost.run:host-gateway",
		"local.graphql.nhost.run:host-gateway",
		"local.hasura.nhost.run:host-gateway",
		"local.storage.nhost.run:host-gateway",
	}
}

const traefikConfig = `
# v1
# DO NOT EDIT THIS FILE
tls:
  certificates:
    - certFile: /opt/traefik/certs/local.crt
      keyFile: /opt/traefik/certs/local.key
    - certFile: /opt/traefik/certs/sub.crt
      keyFile: /opt/traefik/certs/sub.key
log:
  level: DEBUG
accessLog: {}
`

func dumpCert(
	cert []byte,
	key []byte,
	dstName string,
	dotnhostfolder string,
) error {
	f1, err := os.OpenFile(
		filepath.Join(dotnhostfolder, "traefik", "certs", dstName+".crt"),
		os.O_TRUNC|os.O_CREATE|os.O_WRONLY,
		0o644, //nolint:mnd
	)
	if err != nil {
		return fmt.Errorf("failed to open local.crt: %w", err)
	}
	defer f1.Close()

	if _, err := f1.Write(cert); err != nil {
		return fmt.Errorf("failed to write local.crt: %w", err)
	}

	f2, err := os.OpenFile(
		filepath.Join(dotnhostfolder, "traefik", "certs", dstName+".key"),
		os.O_TRUNC|os.O_CREATE|os.O_WRONLY,
		0o644, //nolint:mnd
	)
	if err != nil {
		return fmt.Errorf("failed to open local.key: %w", err)
	}
	defer f2.Close()

	if _, err := f2.Write(key); err != nil {
		return fmt.Errorf("failed to write local.cert: %w", err)
	}

	return nil
}

func trafikFiles(dotnhostfolder string) error {
	if err := os.MkdirAll(
		filepath.Join(dotnhostfolder, "traefik", "certs"),
		0o755, //nolint:mnd
	); err != nil {
		return fmt.Errorf("failed to create traefik folder: %w", err)
	}

	if err := dumpCert(ssl.LocalCertFile, ssl.LocalKeyFile, "local", dotnhostfolder); err != nil {
		return fmt.Errorf("failed to dump local cert: %w", err)
	}

	if err := dumpCert(ssl.SubCertFile, ssl.SubKeyFile, "sub", dotnhostfolder); err != nil {
		return fmt.Errorf("failed to dump sub cert: %w", err)
	}

	f, err := os.OpenFile(
		filepath.Join(dotnhostfolder, "traefik", "traefik.yaml"),
		os.O_TRUNC|os.O_CREATE|os.O_WRONLY,
		0o644, //nolint:mnd
	)
	if err != nil {
		return fmt.Errorf("failed to open traefik.yaml: %w", err)
	}
	defer f.Close()

	if _, err := f.WriteString(traefikConfig); err != nil {
		return fmt.Errorf("failed to write traefik.yaml: %w", err)
	}

	return nil
}

func traefik(
	subdomain, projectName string,
	port uint,
	dotnhostfolder string,
	dockerURL *url.URL,
) (*Service, error) {
	if err := trafikFiles(dotnhostfolder); err != nil {
		return nil, fmt.Errorf("failed to create traefik files: %w", err)
	}

	volumes := []Volume{{
		Type:     "bind",
		Source:   filepath.Join(dotnhostfolder, "traefik"),
		Target:   "/opt/traefik",
		ReadOnly: new(true),
	}}

	containerDockerEndpoint := dockerURL.String()
	if dockerURL.Scheme == "unix" {
		volumes = append(volumes, Volume{
			Type:     "bind",
			Source:   dockerURL.Path,
			Target:   "/var/run/docker.sock",
			ReadOnly: new(true),
			// Relabel the socket with a shared SELinux label so traefik can
			// reach the docker daemon on SELinux/Podman hosts (no-op elsewhere).
			Bind: &BindOptions{SELinux: "z"},
		})
		containerDockerEndpoint = defaultDockerEndpoint
	}

	return &Service{
		Image:      "traefik:v3.6",
		DependsOn:  nil,
		EntryPoint: nil,
		Command: []string{
			"--api.insecure=true",
			"--providers.docker=true",
			"--providers.docker.endpoint=" + containerDockerEndpoint,
			"--providers.file.directory=/opt/traefik",
			"--providers.file.watch=true",
			"--providers.docker.exposedbydefault=false",
			fmt.Sprintf(
				"--providers.docker.constraints=Label(`com.docker.compose.project`,`%s`)",
				projectName,
			),
			fmt.Sprintf("--entrypoints.web.address=:%d", port),
		},
		Environment: nil,
		ExtraHosts:  extraHosts,
		HealthCheck: nil,
		Labels:      nil,
		Networks:    networkAliases(traefikAliases(subdomain)...),
		Ports: []Port{
			{
				Mode:      "ingress",
				Target:    port,
				Published: strconv.FormatUint(uint64(port), 10),
				Protocol:  "tcp",
			},
		},
		Restart:    "always",
		User:       nil,
		Volumes:    volumes,
		WorkingDir: nil,
	}, nil
}

func minio(volumeName string) *Service {
	return &Service{
		Image:      "nhost/minio:RELEASE.2025-02-28T09-55-16Z",
		DependsOn:  nil,
		EntryPoint: []string{"/bin/sh"},
		Command: []string{
			"-c", "mkdir -p /data/nhost && /usr/bin/minio server --address :9000 /data",
		},
		Environment: map[string]string{
			"MINIO_ROOT_PASSWORD": "minioaccesskey123123",
			"MINIO_ROOT_USER":     "minioaccesskey123123",
		},
		ExtraHosts:  extraHosts,
		Ports:       nil,
		Restart:     "always",
		User:        nil,
		HealthCheck: nil,
		Labels:      nil,
		Networks:    nil,
		Volumes: []Volume{
			{
				Type:     "volume",
				Source:   volumeName,
				Target:   "/data",
				ReadOnly: nil,
			},
		},
		WorkingDir: nil,
	}
}

func dashboard( //nolint:funlen // single env-var config map, not decomposable
	cfg *model.ConfigConfig,
	subdomain string,
	dashboardVersion string,
	httpPort uint,
	useTLS bool,
	appID string,
) *Service {
	// With constellation enabled, the dashboard's hasura admin/metadata API
	// calls flow through constellation (which proxies unmatched paths to hasura)
	// instead of hitting hasura directly. Console UI and migrations API stay on
	// the hasura-cli helper containers and are not affected.
	hasuraAPISubdomain := "hasura"
	if cfg.GetExperimental().GetConstellation() != nil {
		hasuraAPISubdomain = "graphql"
	}

	return &Service{
		Image:      dashboardVersion,
		DependsOn:  nil,
		EntryPoint: nil,
		Command:    nil,
		Environment: map[string]string{
			"NEXT_PUBLIC_ENV":                "dev",
			"NEXT_PUBLIC_NHOST_PLATFORM":     "false",
			"NEXT_PUBLIC_NHOST_APP_ID":       appID,
			"NEXT_PUBLIC_NHOST_ADMIN_SECRET": cfg.Hasura.AdminSecret,
			"NEXT_PUBLIC_NHOST_AUTH_URL": URL(
				subdomain, "auth", httpPort, useTLS,
			) + "/v1",
			"NEXT_PUBLIC_NHOST_CONFIGSERVER_URL": URL(
				subdomain, "dashboard", httpPort, useTLS,
			) + "/v1/configserver/graphql",
			"NEXT_PUBLIC_NHOST_FUNCTIONS_URL": URL(
				subdomain, "functions", httpPort, useTLS,
			) + "/v1",
			"NEXT_PUBLIC_NHOST_LOGS_GRAPHQL_URL": URL(
				subdomain, "dashboard", httpPort, useTLS,
			) + "/v1/logs/graphql",
			"NEXT_PUBLIC_NHOST_LOGS_WEBSOCKET": WebsocketURL(
				subdomain, "dashboard", httpPort, useTLS,
			) + "/v1/logs/graphql",
			"NEXT_PUBLIC_NHOST_GRAPHQL_URL": URL(
				subdomain, "graphql", httpPort, useTLS,
			) + "/v1",
			"NEXT_PUBLIC_NHOST_HASURA_API_URL": URL(
				subdomain, hasuraAPISubdomain, httpPort, useTLS,
			),
			"NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL": URL(
				subdomain, "hasura", httpPort, useTLS,
			) + "/console",
			"NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL": URL(
				subdomain, "hasura", httpPort, useTLS,
			) + "/apis/migrate",
			"NEXT_PUBLIC_NHOST_STORAGE_URL": URL(
				subdomain, "storage", httpPort, useTLS,
			) + "/v1",
		},
		ExtraHosts:  extraHosts,
		HealthCheck: nil,
		Labels: Ingresses{
			{
				Name:    "dashboard",
				TLS:     useTLS,
				Rule:    traefikHostMatch("dashboard"),
				Port:    dashboardPort,
				Rewrite: nil,
			},
		}.Labels(),
		Networks:   nil,
		Ports:      []Port{},
		Restart:    "",
		User:       nil,
		Volumes:    []Volume{},
		WorkingDir: new(string),
	}
}

func stripJWTSecretToPublic(value string) (string, error) {
	var full map[string]any
	if err := json.Unmarshal([]byte(value), &full); err != nil {
		return value, nil //nolint:nilerr
	}

	public := make(map[string]any)
	if v, ok := full["key"]; ok {
		public["key"] = v
	}

	if v, ok := full["type"]; ok {
		public["type"] = v
	}

	b, err := json.Marshal(public)
	if err != nil {
		return "", fmt.Errorf("failed to marshal JWT secret: %w", err)
	}

	return string(b), nil
}

func functions( //nolint:funlen
	cfg *model.ConfigConfig,
	subdomain string,
	httpPort uint,
	useTLS bool,
	rootFolder string,
	jwtSecret string,
	port uint,
	branch string,
	functionsVersion string,
) (*Service, error) {
	jwtSecret, err := stripJWTSecretToPublic(jwtSecret)
	if err != nil {
		return nil, fmt.Errorf("failed to strip JWT secret for %s: %w", "functions", err)
	}

	envVars := map[string]string{
		"HASURA_GRAPHQL_ADMIN_SECRET": cfg.Hasura.AdminSecret,
		"HASURA_GRAPHQL_DATABASE_URL": "postgres://nhost_auth_admin@local.db.nhost.run:5432/local",
		"HASURA_GRAPHQL_GRAPHQL_URL":  "http://graphql:8080/v1/graphql",
		"HASURA_GRAPHQL_JWT_SECRET":   jwtSecret,
		"NHOST_ADMIN_SECRET":          cfg.Hasura.AdminSecret,
		"NHOST_AUTH_URL":              URL(subdomain, "auth", httpPort, useTLS) + "/v1",
		"NHOST_FUNCTIONS_URL":         URL(subdomain, "functions", httpPort, useTLS) + "/v1",
		"NHOST_GRAPHQL_URL":           URL(subdomain, "graphql", httpPort, useTLS) + "/v1",
		"NHOST_HASURA_URL":            URL(subdomain, "hasura", httpPort, useTLS) + "/console",
		"NHOST_STORAGE_URL":           URL(subdomain, "storage", httpPort, useTLS) + "/v1",
		"NHOST_JWT_SECRET":            jwtSecret,
		"NHOST_REGION":                "local",
		"NHOST_SUBDOMAIN":             subdomain,
		"NHOST_WEBHOOK_SECRET":        cfg.Hasura.WebhookSecret,
		"GRAPHITE_WEBHOOK_SECRET":     cfg.GetAi().GetWebhookSecret(),
	}
	for _, envVar := range cfg.GetGlobal().GetEnvironment() {
		envVars[envVar.GetName()] = envVar.GetValue()
	}

	return &Service{
		Image: fmt.Sprintf(
			"nhost/functions:%d-%s",
			*cfg.GetFunctions().GetNode().Version,
			functionsVersion,
		),
		DependsOn:   nil,
		EntryPoint:  nil,
		Command:     nil,
		Environment: envVars,
		ExtraHosts:  extraHosts,
		HealthCheck: &HealthCheck{
			Test:        []string{"CMD", "wget", "--spider", "-S", "http://localhost:3000/healthz"},
			Interval:    "5s",
			Timeout:     "600s",
			StartPeriod: "600s",
		},
		Labels: Ingresses{
			{
				Name: "functions",
				TLS:  useTLS,
				Rule: traefikHostMatch("functions") + "&& PathPrefix(`/v1`)",
				Port: functionsPort,
				Rewrite: &Rewrite{
					Regex:       "/v1(/|$$)(.*)",
					Replacement: "/$$2",
				},
			},
		}.Labels(),
		Networks: networkAliases("functions-service"),
		Ports:    ports(port, functionsPort),
		Restart:  "always",
		User:     nil,
		Volumes: []Volume{
			{
				Type:     "bind",
				Source:   rootFolder,
				Target:   "/opt/project",
				ReadOnly: new(false),
			},
			{
				Type:     "volume",
				Source:   rootNodeModules(branch),
				Target:   "/opt/project/node_modules",
				ReadOnly: new(false),
			},
			{
				Type:     "volume",
				Source:   functionsNodeModules(branch),
				Target:   "/opt/project/functions/node_modules",
				ReadOnly: new(false),
			},
		},
		WorkingDir: nil,
	}, nil
}

func mailhog(volumeName string, useTLS bool) *Service {
	return &Service{
		Image:      "nhost/mailhog:v1.0.1",
		DependsOn:  nil,
		EntryPoint: []string{},
		Command:    []string{},
		Environment: map[string]string{
			"SMTP_HOST":   "mailhog",
			"SMTP_PASS":   "password",
			"SMTP_PORT":   "1025",
			"SMTP_SECURE": "false",
			"SMTP_SENDER": "auth@example.com",
			"SMTP_USER":   "user",
		},
		ExtraHosts:  extraHosts,
		HealthCheck: nil,
		Labels: Ingresses{
			{
				Name:    "mailhog",
				TLS:     useTLS,
				Rule:    traefikHostMatch("mailhog"),
				Port:    mailhogPort,
				Rewrite: nil,
			},
		}.Labels(),
		Networks: nil,
		Ports:    nil,
		Restart:  "always",
		User:     nil,
		Volumes: []Volume{
			{
				Type:     "volume",
				Source:   volumeName,
				Target:   "/maildir",
				ReadOnly: new(false),
			},
		},
		WorkingDir: nil,
	}
}

type ExposePorts struct {
	Auth      uint
	Storage   uint
	Graphql   uint
	Console   uint
	Functions uint
}

func sanitizeBranch(name string) string {
	re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
	return strings.ToLower(re.ReplaceAllString(name, ""))
}

func IsJWTSecretCompatibleWithHasuraAuth(
	jwtSecret *model.ConfigJWTSecret,
) bool {
	if jwtSecret != nil && jwtSecret.Type != nil && *jwtSecret.Type != "" && jwtSecret.Key != nil &&
		*jwtSecret.Key != "" {
		return *jwtSecret.Type == "HS256" || *jwtSecret.Type == "HS384" ||
			*jwtSecret.Type == "HS512" || *jwtSecret.Type == "RS256" ||
			*jwtSecret.Type == "RS384" || *jwtSecret.Type == "RS512"
	}

	return false
}

func getServices( //nolint: funlen,cyclop
	cfg *model.ConfigConfig,
	dockerURL *url.URL,
	subdomain string,
	projectName string,
	httpPort uint,
	useTLS bool,
	postgresPort uint,
	nhostFolder string,
	dotNhostFolder string,
	rootFolder string,
	ports ExposePorts,
	branch string,
	dashboardVersion string,
	functionsVersion string,
	configserviceImage string,
	appID string,
	startFunctions bool,
	hostUser string,
	runServices ...*RunService,
) (map[string]*Service, error) {
	minioVolumeName := "minio_" + sanitizeBranch(branch)
	minio := minio(minioVolumeName)

	storage, err := storage(cfg, subdomain, useTLS, httpPort, ports.Storage)
	if err != nil {
		return nil, err
	}

	pgVolumeName := "pgdata_" + sanitizeBranch(branch)
	dataFolder := filepath.Join(dotNhostFolder, "data")

	postgres, err := postgres(cfg, postgresPort, dataFolder, pgVolumeName)
	if err != nil {
		return nil, err
	}

	graphql, err := graphql(
		cfg,
		subdomain,
		useTLS,
		httpPort,
		ports.Graphql,
	)
	if err != nil {
		return nil, err
	}

	jwtSecret := graphql.Environment["HASURA_GRAPHQL_JWT_SECRET"]

	console, err := console(
		cfg,
		subdomain,
		httpPort,
		useTLS,
		nhostFolder,
		ports.Console,
		hostUser,
	)
	if err != nil {
		return nil, err
	}

	traefik, err := traefik(subdomain, projectName, httpPort, dotNhostFolder, dockerURL)
	if err != nil {
		return nil, err
	}

	mailhogVolumeName := "mailhog_" + sanitizeBranch(branch)
	mailhog := mailhog(mailhogVolumeName, useTLS)

	cs := configserver(
		dockerURL,
		configserviceImage,
		rootFolder,
		nhostFolder,
		projectName,
		appID,
		useTLS,
		runServices...,
	)

	services := map[string]*Service{
		"console":      console,
		"dashboard":    dashboard(cfg, subdomain, dashboardVersion, httpPort, useTLS, appID),
		"graphql":      graphql,
		"minio":        minio,
		"postgres":     postgres,
		"storage":      storage,
		"mailhog":      mailhog,
		"traefik":      traefik,
		"configserver": cs,
	}

	if startFunctions {
		services["functions"], err = functions(
			cfg,
			subdomain,
			httpPort,
			useTLS,
			rootFolder,
			jwtSecret,
			ports.Functions,
			branch,
			functionsVersion,
		)
		if err != nil {
			return nil, err
		}
	}

	if cfg.GetExperimental().GetConstellation() != nil {
		c, err := constellation(
			cfg,
			subdomain,
			useTLS,
			httpPort,
			nhostFolder,
			"nhost/constellation:"+*cfg.GetExperimental().GetConstellation().GetVersion(),
			hostUser,
		)
		if err != nil {
			return nil, err
		}

		services["constellation"] = c
	}

	if len(cfg.GetHasura().GetJwtSecrets()) > 0 &&
		IsJWTSecretCompatibleWithHasuraAuth(cfg.GetHasura().GetJwtSecrets()[0]) &&
		cfg.GetHasura().GetAuthHook() == nil {
		auth, err := auth(cfg, subdomain, httpPort, useTLS, nhostFolder, ports.Auth)
		if err != nil {
			return nil, err
		}

		services["auth"] = auth

		if cfg.Ai != nil {
			services["ai"] = ai(cfg)
		}
	}

	for _, runService := range runServices {
		svc := run(runService.Config, branch)

		if len(runService.BindMounts) > 0 {
			svc.Volumes = append(svc.Volumes, runService.BindMounts...)
		}

		services["run-"+runService.Config.Name] = svc
	}

	return services, nil
}

type RunService struct {
	Config     *model.ConfigRunServiceConfig
	Path       string
	BindMounts []Volume
}

func mountCACertificates(
	path string,
	services map[string]*Service,
) {
	for _, service := range services {
		service.Volumes = append(service.Volumes, Volume{
			Type:     "bind",
			Source:   path,
			Target:   "/etc/ssl/certs/ca-certificates.crt",
			ReadOnly: new(true),
		})
	}
}

// osLinux is runtime.GOOS on Linux hosts.
const osLinux = "linux"

// hostUserSpec returns the `user: <uid>:<gid>` value that makes a
// container write host-visible files (migrations, metadata, generated
// config) as the caller instead of root, or nil when host-user mapping
// must not be applied. The mapping is resolved by ResolveHostUser.
//
// Only services that write into user-owned bind mounts should use this.
// The `functions` service must not: its Nix-built image ships a
// read-only /tmp (mode 0555) and relies on root's DAC_OVERRIDE to
// create /tmp/corepack-shims. The `configserver` service must not
// either: it bind-mounts the host Docker socket (owned by root:docker,
// reached via the caller's `docker` supplementary group), and forcing a
// primary gid drops those supplementary groups and loses socket access.
func hostUserSpec(hostUser string) *string {
	if hostUser == "" {
		return nil
	}

	return &hostUser
}

func ComposeFileFromConfig( //nolint:funlen
	ctx context.Context,
	cfg *model.ConfigConfig,
	subdomain string,
	projectName string,
	httpPort uint,
	useTLS bool,
	postgresPort uint,
	nhostFolder string,
	dotNhostFolder string,
	rootFolder string,
	ports ExposePorts,
	branch string,
	dashboardVersion string,
	functionsVersion string,
	configserverImage string,
	appID string,
	startFunctions bool,
	hostUser string,
	caCertificatesPath string,
	runServices ...*RunService,
) (*ComposeFile, error) {
	dockerURL, err := resolveDockerHost(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to get Docker host: %w", err)
	}

	services, err := getServices(
		cfg,
		dockerURL,
		subdomain,
		projectName,
		httpPort,
		useTLS,
		postgresPort,
		nhostFolder,
		dotNhostFolder,
		rootFolder,
		ports,
		branch,
		dashboardVersion,
		functionsVersion,
		configserverImage,
		appID,
		startFunctions,
		hostUser,
		runServices...,
	)
	if err != nil {
		return nil, err
	}

	volumes := map[string]struct{}{
		rootNodeModules(branch):             {},
		"pgdata_" + sanitizeBranch(branch):  {},
		"minio_" + sanitizeBranch(branch):   {},
		"mailhog_" + sanitizeBranch(branch): {},
	}

	if startFunctions {
		volumes[functionsNodeModules(branch)] = struct{}{}
	}

	for _, runService := range runServices {
		for _, s := range runService.Config.GetResources().GetStorage() {
			volumes[runVolumeName(runService.Config.Name, s.GetName(), branch)] = struct{}{}
		}
	}

	if caCertificatesPath != "" {
		mountCACertificates(caCertificatesPath, services)
	}

	return &ComposeFile{
		Services: services,
		Volumes:  volumes,
	}, nil
}