github.com/drud/ddev@v1.21.5-alpha1.0.20230226034409-94fcc4b94453/pkg/ddevapp/config.go (about)

     1  package ddevapp
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"sort"
     9  	"strings"
    10  	"text/template"
    11  	"time"
    12  
    13  	"github.com/Masterminds/sprig/v3"
    14  	"github.com/drud/ddev/pkg/dockerutil"
    15  	"github.com/drud/ddev/pkg/globalconfig"
    16  	"github.com/drud/ddev/pkg/nodeps"
    17  	"github.com/drud/ddev/pkg/versionconstants"
    18  	copy2 "github.com/otiai10/copy"
    19  
    20  	"regexp"
    21  
    22  	"runtime"
    23  
    24  	"github.com/drud/ddev/pkg/fileutil"
    25  	"github.com/drud/ddev/pkg/output"
    26  	"github.com/drud/ddev/pkg/util"
    27  	log "github.com/sirupsen/logrus"
    28  	"gopkg.in/yaml.v3"
    29  )
    30  
    31  // Regexp pattern to determine if a hostname is valid per RFC 1123.
    32  var hostRegex = regexp.MustCompile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
    33  
    34  // init() is for testing situations only, allowing us to override the default webserver type
    35  // or caching behavior
    36  
    37  func init() {
    38  	// This is for automated testing only. It allows us to override the webserver type.
    39  	if testWebServerType := os.Getenv("DDEV_TEST_WEBSERVER_TYPE"); testWebServerType != "" {
    40  		nodeps.WebserverDefault = testWebServerType
    41  	}
    42  	if testNFSMount := os.Getenv("DDEV_TEST_USE_NFSMOUNT"); testNFSMount != "" {
    43  		nodeps.NFSMountEnabledDefault = true
    44  	}
    45  	if testMutagen := os.Getenv("DDEV_TEST_USE_MUTAGEN"); testMutagen == "true" {
    46  		nodeps.MutagenEnabledDefault = true
    47  	}
    48  	if os.Getenv("DDEV_TEST_NO_BIND_MOUNTS") == "true" {
    49  		nodeps.NoBindMountsDefault = true
    50  	}
    51  	if os.Getenv("DDEV_TEST_USE_TRAEFIK") == "true" {
    52  		nodeps.UseTraefikDefault = true
    53  	}
    54  
    55  }
    56  
    57  // NewApp creates a new DdevApp struct with defaults set and overridden by any existing config.yml.
    58  func NewApp(appRoot string, includeOverrides bool) (*DdevApp, error) {
    59  	runTime := util.TimeTrack(time.Now(), fmt.Sprintf("ddevapp.NewApp(%s)", appRoot))
    60  	defer runTime()
    61  
    62  	app := &DdevApp{}
    63  
    64  	if appRoot == "" {
    65  		app.AppRoot, _ = os.Getwd()
    66  	} else {
    67  		app.AppRoot = appRoot
    68  	}
    69  
    70  	homeDir, _ := os.UserHomeDir()
    71  	if appRoot == filepath.Dir(globalconfig.GetGlobalDdevDir()) || app.AppRoot == homeDir {
    72  		return nil, fmt.Errorf("ddev config is not useful in your home directory (%s)", homeDir)
    73  	}
    74  
    75  	if !fileutil.FileExists(app.AppRoot) {
    76  		return app, fmt.Errorf("project root %s does not exist", app.AppRoot)
    77  	}
    78  	app.ConfigPath = app.GetConfigPath("config.yaml")
    79  	app.Type = nodeps.AppTypePHP
    80  	app.PHPVersion = nodeps.PHPDefault
    81  	app.ComposerVersion = nodeps.ComposerDefault
    82  	app.NodeJSVersion = nodeps.NodeJSDefault
    83  	app.WebserverType = nodeps.WebserverDefault
    84  	app.NFSMountEnabled = nodeps.NFSMountEnabledDefault
    85  	app.NFSMountEnabledGlobal = globalconfig.DdevGlobalConfig.NFSMountEnabledGlobal
    86  	app.MutagenEnabled = nodeps.MutagenEnabledDefault
    87  	app.MutagenEnabledGlobal = globalconfig.DdevGlobalConfig.MutagenEnabledGlobal
    88  	app.FailOnHookFail = nodeps.FailOnHookFailDefault
    89  	app.FailOnHookFailGlobal = globalconfig.DdevGlobalConfig.FailOnHookFailGlobal
    90  	app.RouterHTTPPort = nodeps.DdevDefaultRouterHTTPPort
    91  	app.RouterHTTPSPort = nodeps.DdevDefaultRouterHTTPSPort
    92  	app.PHPMyAdminPort = nodeps.DdevDefaultPHPMyAdminPort
    93  	app.PHPMyAdminHTTPSPort = nodeps.DdevDefaultPHPMyAdminHTTPSPort
    94  	app.MailhogPort = nodeps.DdevDefaultMailhogPort
    95  	app.MailhogHTTPSPort = nodeps.DdevDefaultMailhogHTTPSPort
    96  
    97  	// Provide a default app name based on directory name
    98  	app.Name = filepath.Base(app.AppRoot)
    99  
   100  	// Gather containers to omit, adding ddev-router for gitpod/codespaces
   101  	app.OmitContainersGlobal = globalconfig.DdevGlobalConfig.OmitContainersGlobal
   102  	if nodeps.IsGitpod() || nodeps.IsCodespaces() {
   103  		app.OmitContainersGlobal = append(app.OmitContainersGlobal, "ddev-router")
   104  	}
   105  
   106  	app.ProjectTLD = globalconfig.DdevGlobalConfig.ProjectTldGlobal
   107  	if globalconfig.DdevGlobalConfig.ProjectTldGlobal == "" {
   108  		app.ProjectTLD = nodeps.DdevDefaultTLD
   109  	}
   110  	app.UseDNSWhenPossible = true
   111  
   112  	app.WebImage = versionconstants.GetWebImage()
   113  
   114  	// Load from file if available. This will return an error if the file doesn't exist,
   115  	// and it is up to the caller to determine if that's an issue.
   116  	if _, err := os.Stat(app.ConfigPath); !os.IsNotExist(err) {
   117  		_, err = app.ReadConfig(includeOverrides)
   118  		if err != nil {
   119  			return app, fmt.Errorf("%v exists but cannot be read. It may be invalid due to a syntax error.: %v", app.ConfigPath, err)
   120  		}
   121  	}
   122  
   123  	// Upgrade any pre-v1.19.0 config that has mariadb_version or mysql_version
   124  	if app.MariaDBVersion != "" {
   125  		app.Database = DatabaseDesc{Type: nodeps.MariaDB, Version: app.MariaDBVersion}
   126  		app.MariaDBVersion = ""
   127  	}
   128  	if app.MySQLVersion != "" {
   129  		app.Database = DatabaseDesc{Type: nodeps.MySQL, Version: app.MySQLVersion}
   130  		app.MySQLVersion = ""
   131  	}
   132  	if app.Database.Type == "" {
   133  		app.Database = DatabaseDefault
   134  	}
   135  	if app.DefaultContainerTimeout == "" {
   136  		app.DefaultContainerTimeout = nodeps.DefaultDefaultContainerTimeout
   137  	}
   138  
   139  	app.SetApptypeSettingsPaths()
   140  
   141  	// Rendered yaml is not there until after ddev config or ddev start
   142  	if fileutil.FileExists(app.ConfigPath) && fileutil.FileExists(app.DockerComposeFullRenderedYAMLPath()) {
   143  		content, err := fileutil.ReadFileIntoString(app.DockerComposeFullRenderedYAMLPath())
   144  		if err != nil {
   145  			return app, err
   146  		}
   147  		err = app.UpdateComposeYaml(content)
   148  		if err != nil {
   149  			return app, err
   150  		}
   151  	}
   152  	return app, nil
   153  }
   154  
   155  // GetConfigPath returns the path to an application config file specified by filename.
   156  func (app *DdevApp) GetConfigPath(filename string) string {
   157  	return filepath.Join(app.AppRoot, ".ddev", filename)
   158  }
   159  
   160  // WriteConfig writes the app configuration into the .ddev folder.
   161  func (app *DdevApp) WriteConfig() error {
   162  
   163  	// Work against a copy of the DdevApp, since we don't want to actually change it.
   164  	appcopy := *app
   165  
   166  	// Only set the images on write if non-default values have been specified.
   167  	if appcopy.WebImage == versionconstants.GetWebImage() {
   168  		appcopy.WebImage = ""
   169  	}
   170  	if appcopy.MailhogPort == nodeps.DdevDefaultMailhogPort {
   171  		appcopy.MailhogPort = ""
   172  	}
   173  	if appcopy.MailhogHTTPSPort == nodeps.DdevDefaultMailhogHTTPSPort {
   174  		appcopy.MailhogHTTPSPort = ""
   175  	}
   176  	if appcopy.PHPMyAdminPort == nodeps.DdevDefaultPHPMyAdminPort {
   177  		appcopy.PHPMyAdminPort = ""
   178  	}
   179  	if appcopy.PHPMyAdminHTTPSPort == nodeps.DdevDefaultPHPMyAdminHTTPSPort {
   180  		appcopy.PHPMyAdminHTTPSPort = ""
   181  	}
   182  	if appcopy.ProjectTLD == nodeps.DdevDefaultTLD || appcopy.ProjectTLD == globalconfig.DdevGlobalConfig.ProjectTldGlobal {
   183  		appcopy.ProjectTLD = ""
   184  	}
   185  	if appcopy.DefaultContainerTimeout == nodeps.DefaultDefaultContainerTimeout {
   186  		appcopy.DefaultContainerTimeout = ""
   187  	}
   188  
   189  	// We now want to reserve the port we're writing for HostDBPort and HostWebserverPort and so they don't
   190  	// accidentally get used for other projects.
   191  	err := app.UpdateGlobalProjectList()
   192  	if err != nil {
   193  		return err
   194  	}
   195  
   196  	// Don't write default working dir values to config
   197  	defaults := appcopy.DefaultWorkingDirMap()
   198  	for service, defaultWorkingDir := range defaults {
   199  		if app.WorkingDir[service] == defaultWorkingDir {
   200  			delete(appcopy.WorkingDir, service)
   201  		}
   202  	}
   203  
   204  	err = PrepDdevDirectory(&appcopy)
   205  	if err != nil {
   206  		return err
   207  	}
   208  
   209  	cfgbytes, err := yaml.Marshal(appcopy)
   210  	if err != nil {
   211  		return err
   212  	}
   213  
   214  	// Append hook information and sample hook suggestions.
   215  	cfgbytes = append(cfgbytes, []byte(ConfigInstructions)...)
   216  	cfgbytes = append(cfgbytes, appcopy.GetHookDefaultComments()...)
   217  
   218  	err = os.WriteFile(appcopy.ConfigPath, cfgbytes, 0644)
   219  	if err != nil {
   220  		return err
   221  	}
   222  
   223  	// Allow project-specific post-config action
   224  	err = appcopy.PostConfigAction()
   225  	if err != nil {
   226  		return err
   227  	}
   228  
   229  	// Write example Dockerfiles into build directories
   230  	contents := []byte(`
   231  #ddev-generated
   232  # You can copy this Dockerfile.example to Dockerfile to add configuration
   233  # or packages or anything else to your webimage
   234  # These additions will be appended last to ddev's own Dockerfile
   235  RUN npm install --global forever
   236  RUN echo "Built on $(date)" > /build-date.txt
   237  `)
   238  
   239  	err = WriteImageDockerfile(app.GetConfigPath("web-build")+"/Dockerfile.example", contents)
   240  	if err != nil {
   241  		return err
   242  	}
   243  	contents = []byte(`
   244  #ddev-generated
   245  # You can copy this Dockerfile.example to Dockerfile to add configuration
   246  # or packages or anything else to your dbimage
   247  RUN echo "Built on $(date)" > /build-date.txt
   248  `)
   249  
   250  	err = WriteImageDockerfile(app.GetConfigPath("db-build")+"/Dockerfile.example", contents)
   251  	if err != nil {
   252  		return err
   253  	}
   254  
   255  	return nil
   256  }
   257  
   258  // UpdateGlobalProjectList updates any information about project that
   259  // is tracked in global project list:
   260  // - approot
   261  // - configured host ports
   262  // checks that configured host ports are not already
   263  // reserved by another project
   264  func (app *DdevApp) UpdateGlobalProjectList() error {
   265  	portsToReserve := []string{}
   266  	if app.HostDBPort != "" {
   267  		portsToReserve = append(portsToReserve, app.HostDBPort)
   268  	}
   269  	if app.HostWebserverPort != "" {
   270  		portsToReserve = append(portsToReserve, app.HostWebserverPort)
   271  	}
   272  	if app.HostHTTPSPort != "" {
   273  		portsToReserve = append(portsToReserve, app.HostHTTPSPort)
   274  	}
   275  
   276  	if len(portsToReserve) > 0 {
   277  		err := globalconfig.CheckHostPortsAvailable(app.Name, portsToReserve)
   278  		if err != nil {
   279  			return err
   280  		}
   281  	}
   282  	err := globalconfig.ReservePorts(app.Name, portsToReserve)
   283  	if err != nil {
   284  		return err
   285  	}
   286  	err = globalconfig.SetProjectAppRoot(app.Name, app.AppRoot)
   287  	if err != nil {
   288  		return err
   289  	}
   290  
   291  	return nil
   292  }
   293  
   294  // ReadConfig reads project configuration from the config.yaml file
   295  // It does not attempt to set default values; that's NewApp's job.
   296  func (app *DdevApp) ReadConfig(includeOverrides bool) ([]string, error) {
   297  
   298  	// Load base .ddev/config.yaml - original config
   299  	err := app.LoadConfigYamlFile(app.ConfigPath)
   300  	if err != nil {
   301  		return []string{}, fmt.Errorf("unable to load config file %s: %v", app.ConfigPath, err)
   302  	}
   303  
   304  	configOverrides := []string{}
   305  	// Load config.*.y*ml after in glob order
   306  	if includeOverrides {
   307  		glob := filepath.Join(filepath.Dir(app.ConfigPath), "config.*.y*ml")
   308  		configOverrides, err = filepath.Glob(glob)
   309  		if err != nil {
   310  			return []string{}, err
   311  		}
   312  
   313  		for _, item := range configOverrides {
   314  			err = app.mergeAdditionalConfigIntoApp(item)
   315  
   316  			if err != nil {
   317  				return []string{}, fmt.Errorf("unable to load config file %s: %v", item, err)
   318  			}
   319  		}
   320  	}
   321  
   322  	return append([]string{app.ConfigPath}, configOverrides...), nil
   323  }
   324  
   325  // LoadConfigYamlFile loads one config.yaml into app, overriding what might be there.
   326  func (app *DdevApp) LoadConfigYamlFile(filePath string) error {
   327  	source, err := os.ReadFile(filePath)
   328  	if err != nil {
   329  		return fmt.Errorf("could not find an active ddev configuration at %s have you run 'ddev config'? %v", app.ConfigPath, err)
   330  	}
   331  
   332  	// validate extend command keys
   333  	err = validateHookYAML(source)
   334  	if err != nil {
   335  		return fmt.Errorf("invalid configuration in %s: %v", app.ConfigPath, err)
   336  	}
   337  
   338  	// ReadConfig config values from file.
   339  	err = yaml.Unmarshal(source, app)
   340  	if err != nil {
   341  		return err
   342  	}
   343  	return nil
   344  }
   345  
   346  // WarnIfConfigReplace just messages user about whether config is being replaced or created
   347  func (app *DdevApp) WarnIfConfigReplace() {
   348  	if app.ConfigExists() {
   349  		util.Warning("You are reconfiguring the project at %s.\nThe existing configuration will be updated and replaced.", app.AppRoot)
   350  	} else {
   351  		util.Success("Creating a new ddev project config in the current directory (%s)", app.AppRoot)
   352  		util.Success("Once completed, your configuration will be written to %s\n", app.ConfigPath)
   353  	}
   354  }
   355  
   356  // PromptForConfig goes through a set of prompts to receive user input and generate an Config struct.
   357  func (app *DdevApp) PromptForConfig() error {
   358  
   359  	app.WarnIfConfigReplace()
   360  
   361  	for {
   362  		err := app.promptForName()
   363  
   364  		if err == nil {
   365  			break
   366  		}
   367  
   368  		output.UserOut.Printf("%v", err)
   369  	}
   370  
   371  	if err := app.docrootPrompt(); err != nil {
   372  		return err
   373  	}
   374  
   375  	err := app.AppTypePrompt()
   376  	if err != nil {
   377  		return err
   378  	}
   379  
   380  	err = app.ConfigFileOverrideAction()
   381  	if err != nil {
   382  		return err
   383  	}
   384  
   385  	err = app.ValidateConfig()
   386  	if err != nil {
   387  		return err
   388  	}
   389  
   390  	return nil
   391  }
   392  
   393  // ValidateProjectName checks to see if the project name works for a proper hostname
   394  func ValidateProjectName(name string) error {
   395  	match := hostRegex.MatchString(name)
   396  	if !match {
   397  		return fmt.Errorf("%s is not a valid project name. Please enter a project name in your configuration that will allow for a valid hostname. See https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_hostnames for valid hostname requirements", name)
   398  	}
   399  	return nil
   400  }
   401  
   402  // ValidateConfig ensures the configuration meets ddev's requirements.
   403  func (app *DdevApp) ValidateConfig() error {
   404  
   405  	// validate project name
   406  	if err := ValidateProjectName(app.Name); err != nil {
   407  		return err
   408  	}
   409  
   410  	// validate hostnames
   411  	for _, hn := range app.GetHostnames() {
   412  		// If they have provided "*.<hostname>" then ignore the *. part.
   413  		hn = strings.TrimPrefix(hn, "*.")
   414  		if hn == "ddev.site" {
   415  			return fmt.Errorf("wildcarding the full hostname or using 'ddev.site' as fqdn is not allowed because other projects would not work in that case")
   416  		}
   417  		if !hostRegex.MatchString(hn) {
   418  			return fmt.Errorf("invalid hostname: %s. See https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_hostnames for valid hostname requirements", hn).(invalidHostname)
   419  		}
   420  	}
   421  
   422  	// validate apptype
   423  	if !IsValidAppType(app.Type) {
   424  		return fmt.Errorf("invalid app type: %s", app.Type).(invalidAppType)
   425  	}
   426  
   427  	// validate PHP version
   428  	if !nodeps.IsValidPHPVersion(app.PHPVersion) {
   429  		return fmt.Errorf("unsupported PHP version: %s, ddev only supports the following versions: %v", app.PHPVersion, nodeps.GetValidPHPVersions()).(invalidPHPVersion)
   430  	}
   431  
   432  	// validate webserver type
   433  	if !nodeps.IsValidWebserverType(app.WebserverType) {
   434  		return fmt.Errorf("unsupported webserver type: %s, ddev (%s) only supports the following webserver types: %s", app.WebserverType, runtime.GOARCH, nodeps.GetValidWebserverTypes()).(invalidWebserverType)
   435  	}
   436  
   437  	if !nodeps.IsValidNodeVersion(app.NodeJSVersion) {
   438  		return fmt.Errorf("unsupported system Node.js version: '%s'; for the system Node.js version ddev only supports %s. However, you can use 'ddev nvm install' at runtime to use any supported version", app.NodeJSVersion, nodeps.GetValidNodeVersions())
   439  	}
   440  
   441  	if !nodeps.IsValidOmitContainers(app.OmitContainers) {
   442  		return fmt.Errorf("unsupported omit_containers: %s, ddev (%s) only supports the following for omit_containers: %s", app.OmitContainers, runtime.GOARCH, nodeps.GetValidOmitContainers()).(InvalidOmitContainers)
   443  	}
   444  
   445  	if !nodeps.IsValidDatabaseVersion(app.Database.Type, app.Database.Version) {
   446  		return fmt.Errorf("unsupported database type/version: '%s:%s', ddev %s only supports the following database types and versions: mariadb: %v, mysql: %v, postgres: %v", app.Database.Type, app.Database.Version, runtime.GOARCH, nodeps.GetValidMariaDBVersions(), nodeps.GetValidMySQLVersions(), nodeps.GetValidPostgresVersions())
   447  	}
   448  
   449  	// This check is too intensive for app.Init() and ddevapp.GetActiveApp(), slows things down dramatically
   450  	// If the database already exists in volume and is not of this type, then throw an error
   451  	//if !nodeps.ArrayContainsString(app.GetOmittedContainers(), "db") {
   452  	//	if dbType, err := app.GetExistingDBType(); err != nil || (dbType != "" && dbType != app.Database.Type+":"+app.Database.Version) {
   453  	//		return fmt.Errorf("Unable to configure project %s with database type %s because that database type does not match the current actual database. Please change your database type back to %s and start again, export, delete, and then change configuration and start. To get back to existing type use 'ddev config --database=%s', see docs at %s", app.Name, dbType, dbType, dbType, "https://ddev.readthedocs.io/en/latest/users/extend/database-types/")
   454  	//	}
   455  	//}
   456  
   457  	// golang on windows is not able to time.LoadLocation unless
   458  	// go is installed... so skip validation on Windows
   459  	if runtime.GOOS != "windows" {
   460  		_, err := time.LoadLocation(app.Timezone)
   461  		if err != nil {
   462  			// golang on Windows is often not able to time.LoadLocation.
   463  			// It often works if go is installed and $GOROOT is set, but
   464  			// that's not the norm for our users.
   465  			return fmt.Errorf("invalid timezone %s: %v", app.Timezone, err)
   466  		}
   467  	}
   468  
   469  	//if app.Database.Type == nodeps.Postgres && (nodeps.ArrayContainsString([]string{"wordpress", "magento", "magento2"}, app.Type)) {
   470  	//	return fmt.Errorf("project type %s does not support postgres database", app.Type)
   471  	//}
   472  
   473  	return nil
   474  }
   475  
   476  // DockerComposeYAMLPath returns the absolute path to where the
   477  // base generated yaml file should exist for this project.
   478  func (app *DdevApp) DockerComposeYAMLPath() string {
   479  	return app.GetConfigPath(".ddev-docker-compose-base.yaml")
   480  }
   481  
   482  // DockerComposeFullRenderedYAMLPath returns the absolute path to where the
   483  // the complete generated yaml file should exist for this project.
   484  func (app *DdevApp) DockerComposeFullRenderedYAMLPath() string {
   485  	return app.GetConfigPath(".ddev-docker-compose-full.yaml")
   486  }
   487  
   488  // GetHostname returns the primary hostname of the app.
   489  func (app *DdevApp) GetHostname() string {
   490  	return strings.ToLower(app.Name) + "." + app.ProjectTLD
   491  }
   492  
   493  // GetHostnames returns a slice of all the configured hostnames.
   494  func (app *DdevApp) GetHostnames() []string {
   495  
   496  	// Use a map to make sure that we have unique hostnames
   497  	// The value is useless, so just use the int 1 for assignment.
   498  	nameListMap := make(map[string]int)
   499  	nameListArray := []string{}
   500  
   501  	if !IsRouterDisabled(app) {
   502  		for _, name := range app.AdditionalHostnames {
   503  			name = strings.ToLower(name)
   504  			nameListMap[name+"."+app.ProjectTLD] = 1
   505  		}
   506  
   507  		for _, name := range app.AdditionalFQDNs {
   508  			name = strings.ToLower(name)
   509  			nameListMap[name] = 1
   510  		}
   511  
   512  		// Make sure the primary hostname didn't accidentally get added, it will be prepended
   513  		delete(nameListMap, app.GetHostname())
   514  
   515  		// Now walk the map and extract the keys into an array.
   516  		for k := range nameListMap {
   517  			nameListArray = append(nameListArray, k)
   518  		}
   519  		sort.Strings(nameListArray)
   520  		// We want the primary hostname to be first in the list.
   521  		nameListArray = append([]string{app.GetHostname()}, nameListArray...)
   522  	}
   523  	return nameListArray
   524  }
   525  
   526  // CheckCustomConfig warns the user if any custom configuration files are in use.
   527  func (app *DdevApp) CheckCustomConfig() {
   528  
   529  	// Get the path to .ddev for the current app.
   530  	ddevDir := filepath.Dir(app.ConfigPath)
   531  
   532  	customConfig := false
   533  	if _, err := os.Stat(filepath.Join(ddevDir, "nginx-site.conf")); err == nil && app.WebserverType == nodeps.WebserverNginxFPM {
   534  		util.Warning("Using custom nginx configuration in nginx-site.conf")
   535  		customConfig = true
   536  	}
   537  	nginxFullConfigPath := app.GetConfigPath("nginx_full/nginx-site.conf")
   538  	sigFound, _ := fileutil.FgrepStringInFile(nginxFullConfigPath, nodeps.DdevFileSignature)
   539  	if !sigFound && app.WebserverType == nodeps.WebserverNginxFPM {
   540  		util.Warning("Using custom nginx configuration in %s", nginxFullConfigPath)
   541  		customConfig = true
   542  	}
   543  
   544  	apacheFullConfigPath := app.GetConfigPath("apache/apache-site.conf")
   545  	sigFound, _ = fileutil.FgrepStringInFile(apacheFullConfigPath, nodeps.DdevFileSignature)
   546  	if !sigFound && app.WebserverType != nodeps.WebserverNginxFPM {
   547  		util.Warning("Using custom apache configuration in %s", apacheFullConfigPath)
   548  		customConfig = true
   549  	}
   550  
   551  	nginxPath := filepath.Join(ddevDir, "nginx")
   552  	if _, err := os.Stat(nginxPath); err == nil {
   553  		nginxFiles, err := filepath.Glob(nginxPath + "/*.conf")
   554  		util.CheckErr(err)
   555  		if len(nginxFiles) > 0 {
   556  			util.Warning("Using nginx snippets: %v", nginxFiles)
   557  			customConfig = true
   558  		}
   559  	}
   560  
   561  	mysqlPath := filepath.Join(ddevDir, "mysql")
   562  	if _, err := os.Stat(mysqlPath); err == nil {
   563  		mysqlFiles, err := filepath.Glob(mysqlPath + "/*.cnf")
   564  		util.CheckErr(err)
   565  		if len(mysqlFiles) > 0 {
   566  			util.Warning("Using custom mysql configuration: %v", mysqlFiles)
   567  			customConfig = true
   568  		}
   569  	}
   570  
   571  	phpPath := filepath.Join(ddevDir, "php")
   572  	if _, err := os.Stat(phpPath); err == nil {
   573  		phpFiles, err := filepath.Glob(phpPath + "/*.ini")
   574  		util.CheckErr(err)
   575  		if len(phpFiles) > 0 {
   576  			util.Warning("Using custom PHP configuration: %v", phpFiles)
   577  			customConfig = true
   578  		}
   579  	}
   580  
   581  	webEntrypointPath := filepath.Join(ddevDir, "web-entrypoint.d")
   582  	if _, err := os.Stat(webEntrypointPath); err == nil {
   583  		entrypointFiles, err := filepath.Glob(webEntrypointPath + "/*.sh")
   584  		util.CheckErr(err)
   585  		if len(entrypointFiles) > 0 {
   586  			util.Warning("Using custom web-entrypoint.d configuration: %v", entrypointFiles)
   587  			customConfig = true
   588  		}
   589  	}
   590  
   591  	if customConfig {
   592  		util.Warning("Custom configuration is updated on restart.\nIf you don't see your custom configuration taking effect, run 'ddev restart'.")
   593  	}
   594  
   595  }
   596  
   597  // CheckDeprecations warns the user if anything in use is deprecated.
   598  func (app *DdevApp) CheckDeprecations() {
   599  
   600  }
   601  
   602  // FixObsolete removes files that may be obsolete, etc.
   603  func (app *DdevApp) FixObsolete() {
   604  
   605  	// Remove old in-project commands (which have been moved to global)
   606  	for _, command := range []string{"db/mysql", "host/launch", "web/xdebug"} {
   607  		cmdPath := app.GetConfigPath(filepath.Join("commands", command))
   608  		signatureFound, err := fileutil.FgrepStringInFile(cmdPath, nodeps.DdevFileSignature)
   609  		if err == nil && signatureFound {
   610  			err = os.Remove(cmdPath)
   611  			if err != nil {
   612  				util.Warning("attempted to remove %s but failed, you may want to remove it manually: %v", cmdPath, err)
   613  			}
   614  		}
   615  	}
   616  
   617  	// Remove old provider/*.example as we migrate to not needing them.
   618  	for _, providerFile := range []string{"platform.yaml.example"} {
   619  		providerFilePath := app.GetConfigPath(filepath.Join("providers", providerFile))
   620  		err := os.Remove(providerFilePath)
   621  		if err == nil {
   622  			util.Success("Removed obsolete file %s", providerFilePath)
   623  		}
   624  	}
   625  
   626  	// Remove old global commands
   627  	for _, command := range []string{"host/yarn"} {
   628  		cmdPath := filepath.Join(globalconfig.GetGlobalDdevDir(), "commands/", command)
   629  		if _, err := os.Stat(cmdPath); err == nil {
   630  			err1 := os.Remove(cmdPath)
   631  			if err1 != nil {
   632  				util.Warning("attempted to remove %s but failed, you may want to remove it manually: %v", cmdPath, err)
   633  			}
   634  		}
   635  	}
   636  }
   637  
   638  type composeYAMLVars struct {
   639  	Name                            string
   640  	Plugin                          string
   641  	AppType                         string
   642  	MailhogPort                     string
   643  	HostMailhogPort                 string
   644  	DBType                          string
   645  	DBVersion                       string
   646  	DBMountDir                      string
   647  	DBAPort                         string
   648  	DBPort                          string
   649  	HostPHPMyAdminPort              string
   650  	DdevGenerated                   string
   651  	HostDockerInternalIP            string
   652  	NFSServerAddr                   string
   653  	DisableSettingsManagement       bool
   654  	MountType                       string
   655  	WebMount                        string
   656  	WebBuildContext                 string
   657  	DBBuildContext                  string
   658  	WebBuildDockerfile              string
   659  	DBBuildDockerfile               string
   660  	SSHAgentBuildContext            string
   661  	OmitDB                          bool
   662  	OmitDBA                         bool
   663  	OmitRouter                      bool
   664  	OmitSSHAgent                    bool
   665  	BindAllInterfaces               bool
   666  	MariaDBVolumeName               string
   667  	PostgresVolumeName              string
   668  	MutagenEnabled                  bool
   669  	MutagenVolumeName               string
   670  	NFSMountEnabled                 bool
   671  	NFSSource                       string
   672  	NFSMountVolumeName              string
   673  	DockerIP                        string
   674  	IsWindowsFS                     bool
   675  	NoProjectMount                  bool
   676  	Hostnames                       []string
   677  	Timezone                        string
   678  	ComposerVersion                 string
   679  	Username                        string
   680  	UID                             string
   681  	GID                             string
   682  	AutoRestartContainers           bool
   683  	FailOnHookFail                  bool
   684  	WebWorkingDir                   string
   685  	DBWorkingDir                    string
   686  	DBAWorkingDir                   string
   687  	WebEnvironment                  []string
   688  	NoBindMounts                    bool
   689  	Docroot                         string
   690  	ContainerUploadDir              string
   691  	HostUploadDir                   string
   692  	GitDirMount                     bool
   693  	IsGitpod                        bool
   694  	IsCodespaces                    bool
   695  	DefaultContainerTimeout         string
   696  	UseHostDockerInternalExtraHosts bool
   697  	WebExtraHTTPPorts               string
   698  	WebExtraHTTPSPorts              string
   699  	WebExtraExposedPorts            string
   700  	EnvFile                         string
   701  }
   702  
   703  // RenderComposeYAML renders the contents of .ddev/.ddev-docker-compose*.
   704  func (app *DdevApp) RenderComposeYAML() (string, error) {
   705  	var doc bytes.Buffer
   706  	var err error
   707  
   708  	hostDockerInternalIP, err := dockerutil.GetHostDockerInternalIP()
   709  	if err != nil {
   710  		util.Warning("Could not determine host.docker.internal IP address: %v", err)
   711  	}
   712  	nfsServerAddr, err := dockerutil.GetNFSServerAddr()
   713  	if err != nil {
   714  		util.Warning("Could not determine NFS server IP address: %v", err)
   715  	}
   716  
   717  	// The fallthrough default for hostDockerInternalIdentifier is the
   718  	// hostDockerInternalHostname == host.docker.internal
   719  
   720  	webEnvironment := globalconfig.DdevGlobalConfig.WebEnvironment
   721  	localWebEnvironment := app.WebEnvironment
   722  	for _, v := range localWebEnvironment {
   723  		// docker-compose won't accept a duplicate environment value
   724  		if !nodeps.ArrayContainsString(webEnvironment, v) {
   725  			webEnvironment = append(webEnvironment, v)
   726  		}
   727  	}
   728  
   729  	uid, gid, username := util.GetContainerUIDGid()
   730  	_, err = app.GetProvider("")
   731  	if err != nil {
   732  		return "", err
   733  	}
   734  
   735  	templateVars := composeYAMLVars{
   736  		Name:                      app.Name,
   737  		Plugin:                    "ddev",
   738  		AppType:                   app.Type,
   739  		MailhogPort:               GetExposedPort(app, "mailhog"),
   740  		HostMailhogPort:           app.HostMailhogPort,
   741  		DBType:                    app.Database.Type,
   742  		DBVersion:                 app.Database.Version,
   743  		DBMountDir:                "/var/lib/mysql",
   744  		DBAPort:                   GetExposedPort(app, "dba"),
   745  		DBPort:                    GetExposedPort(app, "db"),
   746  		HostPHPMyAdminPort:        app.HostPHPMyAdminPort,
   747  		DdevGenerated:             nodeps.DdevFileSignature,
   748  		HostDockerInternalIP:      hostDockerInternalIP,
   749  		NFSServerAddr:             nfsServerAddr,
   750  		DisableSettingsManagement: app.DisableSettingsManagement,
   751  		OmitDB:                    nodeps.ArrayContainsString(app.GetOmittedContainers(), nodeps.DBContainer),
   752  		OmitDBA:                   nodeps.ArrayContainsString(app.GetOmittedContainers(), nodeps.DBAContainer) || nodeps.ArrayContainsString(app.OmitContainers, nodeps.DBContainer),
   753  		OmitRouter:                nodeps.ArrayContainsString(app.GetOmittedContainers(), globalconfig.DdevRouterContainer),
   754  		OmitSSHAgent:              nodeps.ArrayContainsString(app.GetOmittedContainers(), "ddev-ssh-agent"),
   755  		BindAllInterfaces:         app.BindAllInterfaces,
   756  		MutagenEnabled:            app.IsMutagenEnabled() || globalconfig.DdevGlobalConfig.NoBindMounts,
   757  
   758  		NFSMountEnabled:       app.IsNFSMountEnabled(),
   759  		NFSSource:             "",
   760  		IsWindowsFS:           runtime.GOOS == "windows",
   761  		NoProjectMount:        app.NoProjectMount,
   762  		MountType:             "bind",
   763  		WebMount:              "../",
   764  		Hostnames:             app.GetHostnames(),
   765  		Timezone:              app.Timezone,
   766  		ComposerVersion:       app.ComposerVersion,
   767  		Username:              username,
   768  		UID:                   uid,
   769  		GID:                   gid,
   770  		WebBuildContext:       "../",
   771  		DBBuildContext:        "./.dbimageBuild",
   772  		AutoRestartContainers: globalconfig.DdevGlobalConfig.AutoRestartContainers,
   773  		FailOnHookFail:        app.FailOnHookFail || app.FailOnHookFailGlobal,
   774  		WebWorkingDir:         app.GetWorkingDir("web", ""),
   775  		DBWorkingDir:          app.GetWorkingDir("db", ""),
   776  		DBAWorkingDir:         app.GetWorkingDir("dba", ""),
   777  		WebEnvironment:        webEnvironment,
   778  		MariaDBVolumeName:     app.GetMariaDBVolumeName(),
   779  		PostgresVolumeName:    app.GetPostgresVolumeName(),
   780  		NFSMountVolumeName:    app.GetNFSMountVolumeName(),
   781  		NoBindMounts:          globalconfig.DdevGlobalConfig.NoBindMounts,
   782  		Docroot:               app.GetDocroot(),
   783  		HostUploadDir:         app.GetHostUploadDirFullPath(),
   784  		ContainerUploadDir:    app.GetContainerUploadDirFullPath(),
   785  		GitDirMount:           false,
   786  		IsGitpod:              nodeps.IsGitpod(),
   787  		IsCodespaces:          nodeps.IsCodespaces(),
   788  		// Default max time we wait for containers to be healthy
   789  		DefaultContainerTimeout: app.DefaultContainerTimeout,
   790  		// Only use the extra_hosts technique for linux and only if not WSL2
   791  		// If WSL2 we have to figure out other things, see GetHostDockerInternalIP()
   792  		UseHostDockerInternalExtraHosts: runtime.GOOS == "linux" || (dockerutil.IsWSL2() && globalconfig.DdevGlobalConfig.XdebugIDELocation == globalconfig.XdebugIDELocationWSL2),
   793  	}
   794  	// We don't want to bind-mount git dir if it doesn't exist
   795  	if fileutil.IsDirectory(filepath.Join(app.AppRoot, ".git")) {
   796  		templateVars.GitDirMount = true
   797  	}
   798  
   799  	envFile := app.GetConfigPath(".env")
   800  	if fileutil.FileExists(envFile) {
   801  		templateVars.EnvFile = envFile
   802  	}
   803  
   804  	// And we don't want to bind-mount upload dir if it doesn't exist.
   805  	// templateVars.UploadDir is relative path rooted in approot.
   806  	if app.GetHostUploadDirFullPath() == "" || !fileutil.FileExists(app.GetHostUploadDirFullPath()) {
   807  		templateVars.HostUploadDir = ""
   808  		templateVars.ContainerUploadDir = ""
   809  	}
   810  
   811  	webimageExtraHTTPPorts := []string{}
   812  	webimageExtraHTTPSPorts := []string{}
   813  	exposedPorts := []int{}
   814  	for _, a := range app.WebExtraExposedPorts {
   815  		webimageExtraHTTPPorts = append(webimageExtraHTTPPorts, fmt.Sprintf("%d:%d", a.HTTPPort, a.WebContainerPort))
   816  		webimageExtraHTTPSPorts = append(webimageExtraHTTPSPorts, fmt.Sprintf("%d:%d", a.HTTPSPort, a.WebContainerPort))
   817  		exposedPorts = append(exposedPorts, a.WebContainerPort)
   818  	}
   819  	if len(exposedPorts) != 0 {
   820  		templateVars.WebExtraHTTPPorts = "," + strings.Join(webimageExtraHTTPPorts, ",")
   821  		templateVars.WebExtraHTTPSPorts = "," + strings.Join(webimageExtraHTTPSPorts, ",")
   822  
   823  		templateVars.WebExtraExposedPorts = "expose:\n    - "
   824  		// Odd way to join ints into a string from https://stackoverflow.com/a/37533144/215713
   825  		templateVars.WebExtraExposedPorts = templateVars.WebExtraExposedPorts + strings.Trim(strings.Join(strings.Fields(fmt.Sprint(exposedPorts)), "\n    - "), "[]")
   826  	}
   827  
   828  	if app.Database.Type == nodeps.Postgres {
   829  		templateVars.DBMountDir = "/var/lib/postgresql/data"
   830  	}
   831  	if app.IsNFSMountEnabled() {
   832  		templateVars.MountType = "volume"
   833  		templateVars.WebMount = "nfsmount"
   834  		templateVars.NFSSource = app.AppRoot
   835  		// Workaround for Catalina sharing nfs as /System/Volumes/Data
   836  		if runtime.GOOS == "darwin" && fileutil.IsDirectory(filepath.Join("/System/Volumes/Data", app.AppRoot)) {
   837  			templateVars.NFSSource = filepath.Join("/System/Volumes/Data", app.AppRoot)
   838  		}
   839  		if runtime.GOOS == "windows" {
   840  			// WinNFSD can only handle a mountpoint like /C/Users/rfay/workspace/d8git
   841  			// and completely chokes in C:\Users\rfay...
   842  			templateVars.NFSSource = dockerutil.MassageWindowsNFSMount(app.AppRoot)
   843  		}
   844  	}
   845  
   846  	if app.IsMutagenEnabled() {
   847  		templateVars.MutagenVolumeName = GetMutagenVolumeName(app)
   848  	}
   849  
   850  	// Add web and db extra dockerfile info
   851  	// If there is a user-provided Dockerfile, use that as the base and then add
   852  	// our extra stuff like usernames, etc.
   853  	// The db-build and web-build directories are used for context
   854  	// so must exist. They usually do.
   855  
   856  	for _, d := range []string{".webimageBuild", ".dbimageBuild"} {
   857  		err = os.MkdirAll(app.GetConfigPath(d), 0755)
   858  		if err != nil {
   859  			return "", err
   860  		}
   861  		// We must start with a clean base directory
   862  		err := fileutil.PurgeDirectory(app.GetConfigPath(d))
   863  		if err != nil {
   864  			util.Warning("unable to clean up directory %s, you may want to delete it manually: %v", d, err)
   865  		}
   866  	}
   867  	err = os.MkdirAll(app.GetConfigPath("db-build"), 0755)
   868  	if err != nil {
   869  		return "", err
   870  	}
   871  
   872  	err = os.MkdirAll(app.GetConfigPath("web-build"), 0755)
   873  	if err != nil {
   874  		return "", err
   875  	}
   876  
   877  	extraWebContent := "\nRUN mkdir -p /home/$username && chown $username /home/$username && chmod 600 /home/$username/.pgpass"
   878  	extraWebContent = extraWebContent + "\nENV NVM_DIR=/home/$username/.nvm"
   879  	if app.NodeJSVersion != nodeps.NodeJSDefault {
   880  		extraWebContent = extraWebContent + "\nRUN (apt-get remove -y nodejs || true) && (apt purge nodejs || true)"
   881  		// Download of setup_*.sh seems to fail a LOT, probably a problem on their end. So try it twice
   882  		extraWebContent = extraWebContent + fmt.Sprintf("\nRUN curl -sSL --fail -o /tmp/setup_node.sh https://deb.nodesource.com/setup_%s.x  ||  curl -sSL --fail -o /tmp/setup_node.sh https://deb.nodesource.com/setup_%s.sh >/tmp/setup_node.sh", app.NodeJSVersion, app.NodeJSVersion)
   883  		extraWebContent = extraWebContent + "\nRUN bash /tmp/setup_node.sh >/dev/null && apt-get install -y nodejs >/dev/null\n" +
   884  			"RUN npm install --unsafe-perm=true --global gulp-cli yarn || ( npm config set unsafe-perm true && npm install --global gulp-cli yarn )"
   885  	}
   886  
   887  	// Add supervisord config for WebExtraDaemons
   888  	var supervisorGroup []string
   889  	for _, appStart := range app.WebExtraDaemons {
   890  		supervisorGroup = append(supervisorGroup, appStart.Name)
   891  		supervisorConf := fmt.Sprintf(`
   892  [program:%s]
   893  group=webextradaemons
   894  command=bash -c "%s || sleep 2"
   895  directory=%s
   896  autostart=false
   897  autorestart=true
   898  startretries=15
   899  stdout_logfile=/proc/self/fd/2
   900  stdout_logfile_maxbytes=0
   901  redirect_stderr=true
   902  `, appStart.Name, appStart.Command, appStart.Directory)
   903  		err = os.WriteFile(app.GetConfigPath(fmt.Sprintf(".webimageBuild/%s.conf", appStart.Name)), []byte(supervisorConf), 0755)
   904  		if err != nil {
   905  			return "", fmt.Errorf("failed to write .webimageBuild/%s.conf: %v", appStart.Name, err)
   906  		}
   907  		extraWebContent = extraWebContent + fmt.Sprintf("\nADD .ddev/.webimageBuild/%s.conf /etc/supervisor/conf.d\n", appStart.Name)
   908  	}
   909  	if len(supervisorGroup) > 0 {
   910  		err = os.WriteFile(app.GetConfigPath(".webimageBuild/webextradaemons.conf"), []byte("[group:webextradaemons]\nprograms="+strings.Join(supervisorGroup, ",")), 0755)
   911  		if err != nil {
   912  			return "", fmt.Errorf("failed to write .webimageBuild/webextradaemons.conf: %v", err)
   913  		}
   914  		extraWebContent = extraWebContent + "\nADD .ddev/.webimageBuild/webextradaemons.conf /etc/supervisor/conf.d\n"
   915  	}
   916  
   917  	err = WriteBuildDockerfile(app.GetConfigPath(".webimageBuild/Dockerfile"), app.GetConfigPath("web-build"), app.WebImageExtraPackages, app.ComposerVersion, extraWebContent)
   918  	if err != nil {
   919  		return "", err
   920  	}
   921  
   922  	// Add .pgpass to homedir on postgres
   923  	extraDBContent := ""
   924  	if app.Database.Type == nodeps.Postgres {
   925  		// Postgres 9/10/11 upstream images are stretch-based, out of support from Debian.
   926  		// Postgres 9/10 are out of support by Postgres and no new images being pushed, see
   927  		// https://github.com/docker-library/postgres/issues/1012
   928  		// However, they do have a postgres:11-bullseye, but we won't start using it yet
   929  		// because of awkward changes to $DBIMAGE. Postgres 11 will be EOL Nov 2023
   930  		if nodeps.ArrayContainsString([]string{nodeps.Postgres9, nodeps.Postgres10, nodeps.Postgres11}, app.Database.Version) {
   931  			extraDBContent = extraDBContent + `
   932  RUN rm -f /etc/apt/sources.list.d/pgdg.list
   933  RUN apt-get update
   934  RUN apt-get -y install apt-transport-https
   935  RUN printf "deb http://apt-archive.postgresql.org/pub/repos/apt/ stretch-pgdg main" > /etc/apt/sources.list.d/pgdg.list
   936  `
   937  		}
   938  		extraDBContent = extraDBContent + `
   939  ENV PATH $PATH:/usr/lib/postgresql/$PG_MAJOR/bin
   940  ADD postgres_healthcheck.sh /
   941  RUN chmod ugo+rx /postgres_healthcheck.sh
   942  RUN mkdir -p /etc/postgresql/conf.d && chmod 777 /etc/postgresql/conf.d
   943  RUN echo "*:*:db:db:db" > ~postgres/.pgpass && chown postgres:postgres ~postgres/.pgpass && chmod 600 ~postgres/.pgpass && chmod 777 /var/tmp && ln -sf /mnt/ddev_config/postgres/postgresql.conf /etc/postgresql && echo "restore_command = 'true'" >> /var/lib/postgresql/recovery.conf
   944  RUN printf "# TYPE DATABASE USER CIDR-ADDRESS  METHOD \nhost  all  all 0.0.0.0/0 md5\nlocal all all trust\nhost    replication    db             0.0.0.0/0  trust\nhost replication all 0.0.0.0/0 trust\nlocal replication all trust\nlocal replication all peer\n" >/etc/postgresql/pg_hba.conf
   945  RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::="--force-confold" --no-install-recommends --no-install-suggests bzip2 less procps pv vim
   946  `
   947  	}
   948  
   949  	err = WriteBuildDockerfile(app.GetConfigPath(".dbimageBuild/Dockerfile"), app.GetConfigPath("db-build"), app.DBImageExtraPackages, "", extraDBContent)
   950  
   951  	// CopyEmbedAssets of postgres healthcheck has to be done after we WriteBuildDockerfile
   952  	// because that deletes the .dbimageBuild directory
   953  	if app.Database.Type == nodeps.Postgres {
   954  		err = fileutil.CopyEmbedAssets(bundledAssets, "healthcheck", app.GetConfigPath(".dbimageBuild"))
   955  		if err != nil {
   956  			return "", err
   957  		}
   958  	}
   959  
   960  	if err != nil {
   961  		return "", err
   962  	}
   963  
   964  	// SSH agent just needs extra to add the official related user, nothing else
   965  	err = WriteBuildDockerfile(filepath.Join(globalconfig.GetGlobalDdevDir(), ".sshimageBuild/Dockerfile"), "", nil, "", "")
   966  	if err != nil {
   967  		return "", err
   968  	}
   969  
   970  	templateVars.DockerIP, err = dockerutil.GetDockerIP()
   971  	if err != nil {
   972  		return "", err
   973  	}
   974  	if app.BindAllInterfaces {
   975  		templateVars.DockerIP = "0.0.0.0"
   976  	}
   977  
   978  	t, err := template.New("app_compose_template.yaml").Funcs(sprig.TxtFuncMap()).ParseFS(bundledAssets, "app_compose_template.yaml")
   979  	if err != nil {
   980  		return "", err
   981  	}
   982  
   983  	err = t.Execute(&doc, templateVars)
   984  	return doc.String(), err
   985  }
   986  
   987  // WriteBuildDockerfile writes a Dockerfile to be used in the
   988  // docker-compose 'build'
   989  // It may include the contents of .ddev/<container>-build
   990  func WriteBuildDockerfile(fullpath string, userDockerfilePath string, extraPackages []string, composerVersion string, extraContent string) error {
   991  
   992  	// Start with user-built dockerfile if there is one.
   993  	err := os.MkdirAll(filepath.Dir(fullpath), 0755)
   994  	if err != nil {
   995  		return err
   996  	}
   997  
   998  	// Normal starting content is just the arg and base image
   999  	contents := `
  1000  #ddev-generated - Do not modify this file; your modifications will be overwritten.
  1001  
  1002  ### DDEV-injected base Dockerfile contents
  1003  ARG BASE_IMAGE
  1004  FROM $BASE_IMAGE
  1005  `
  1006  	contents = contents + `
  1007  ARG username
  1008  ARG uid
  1009  ARG gid
  1010  ARG DDEV_PHP_VERSION
  1011  RUN (groupadd --gid $gid "$username" || groupadd "$username" || true) && (useradd  -l -m -s "/bin/bash" --gid "$username" --comment '' --uid $uid "$username" || useradd  -l -m -s "/bin/bash" --gid "$username" --comment '' "$username" || useradd  -l -m -s "/bin/bash" --gid "$gid" --comment '' "$username" || useradd -l -m -s "/bin/bash" --comment '' $username )
  1012  `
  1013  	// If there are user pre.Dockerfile* files, insert their contents
  1014  	if userDockerfilePath != "" {
  1015  		files, err := filepath.Glob(userDockerfilePath + "/pre.Dockerfile*")
  1016  		if err != nil {
  1017  			return err
  1018  		}
  1019  
  1020  		for _, file := range files {
  1021  			userContents, err := fileutil.ReadFileIntoString(file)
  1022  			if err != nil {
  1023  				return err
  1024  			}
  1025  
  1026  			contents = contents + "\n\n### From user Dockerfile " + file + ":\n" + userContents
  1027  		}
  1028  	}
  1029  
  1030  	if extraPackages != nil {
  1031  		contents = contents + `
  1032  ### DDEV-injected from webimage_extra_packages or dbimage_extra_packages
  1033  
  1034  RUN apt-get -qq update && DEBIAN_FRONTEND=noninteractive apt-get -qq install -y -o Dpkg::Options::="--force-confold" --no-install-recommends --no-install-suggests ` + strings.Join(extraPackages, " ") + "\n"
  1035  	}
  1036  
  1037  	// For webimage, update to latest composer.
  1038  	if strings.Contains(fullpath, "webimageBuild") {
  1039  		// Version to run composer self-update to the version
  1040  		var composerSelfUpdateArg string
  1041  
  1042  		// Remove leading and trailing spaces
  1043  		composerSelfUpdateArg = strings.TrimSpace(composerVersion)
  1044  
  1045  		// Composer v2 is default
  1046  		if composerSelfUpdateArg == "" {
  1047  			composerSelfUpdateArg = "2"
  1048  		}
  1049  
  1050  		// Major and minor versions have to be provided as option so add '--' prefix.
  1051  		// E.g. a major version can be 1 or 2, a minor version 2.2 or 2.1 etc.
  1052  		if strings.Count(composerVersion, ".") < 2 {
  1053  			composerSelfUpdateArg = "--" + composerSelfUpdateArg
  1054  		}
  1055  
  1056  		// Try composer self-update twice because of troubles with composer downloads
  1057  		// breaking testing.
  1058  		// First of all Composer is updated to latest stable release to ensure
  1059  		// new options of the self-update command can be used properly e.g.
  1060  		// selecting a branch instead of a major version only.
  1061  		contents = contents + fmt.Sprintf(`
  1062  ### DDEV-injected composer update
  1063  RUN export XDEBUG_MODE=off; composer self-update --stable || composer self-update --stable || true; composer self-update %s || composer self-update %s || true
  1064  `, composerSelfUpdateArg, composerSelfUpdateArg)
  1065  	}
  1066  
  1067  	if extraContent != "" {
  1068  		contents = contents + fmt.Sprintf(`
  1069  ### DDEV-injected extra content
  1070  %s
  1071  `, extraContent)
  1072  	}
  1073  
  1074  	// If there are user dockerfiles, appends their contents
  1075  	if userDockerfilePath != "" {
  1076  		files, err := filepath.Glob(userDockerfilePath + "/Dockerfile*")
  1077  		if err != nil {
  1078  			return err
  1079  		}
  1080  
  1081  		for _, file := range files {
  1082  			// Skip the example file
  1083  			if file == userDockerfilePath+"/Dockerfile.example" {
  1084  				continue
  1085  			}
  1086  
  1087  			userContents, err := fileutil.ReadFileIntoString(file)
  1088  			if err != nil {
  1089  				return err
  1090  			}
  1091  
  1092  			// Backward compatible fix, remove unnecessary BASE_IMAGE references
  1093  			re, err := regexp.Compile(`ARG BASE_IMAGE.*\n|FROM \$BASE_IMAGE.*\n`)
  1094  			if err != nil {
  1095  				return err
  1096  			}
  1097  
  1098  			userContents = re.ReplaceAllString(userContents, "")
  1099  			contents = contents + "\n\n### From user Dockerfile " + file + ":\n" + userContents
  1100  		}
  1101  	}
  1102  
  1103  	// Assets in the web-build directory copied to .webimageBuild so .webimageBuild can be "context"
  1104  	// This actually copies the Dockerfile, but it is then immediately overwritten by WriteImageDockerfile()
  1105  	if userDockerfilePath != "" {
  1106  		err = copy2.Copy(userDockerfilePath, filepath.Dir(fullpath))
  1107  		if err != nil {
  1108  			return err
  1109  		}
  1110  	}
  1111  	return WriteImageDockerfile(fullpath, []byte(contents))
  1112  }
  1113  
  1114  // WriteImageDockerfile writes a dockerfile at the fullpath (including the filename)
  1115  func WriteImageDockerfile(fullpath string, contents []byte) error {
  1116  	err := os.MkdirAll(filepath.Dir(fullpath), 0755)
  1117  	if err != nil {
  1118  		return err
  1119  	}
  1120  	err = os.WriteFile(fullpath, contents, 0644)
  1121  	if err != nil {
  1122  		return err
  1123  	}
  1124  	return nil
  1125  }
  1126  
  1127  // prompt for a project name.
  1128  func (app *DdevApp) promptForName() error {
  1129  	if app.Name == "" {
  1130  		dir, err := os.Getwd()
  1131  		// if working directory name is invalid for hostnames, we shouldn't suggest it
  1132  		if err == nil && hostRegex.MatchString(filepath.Base(dir)) {
  1133  			app.Name = filepath.Base(dir)
  1134  		}
  1135  	}
  1136  
  1137  	name := util.Prompt("Project name", app.Name)
  1138  	if err := ValidateProjectName(name); err != nil {
  1139  		return err
  1140  	}
  1141  	app.Name = name
  1142  	return nil
  1143  }
  1144  
  1145  // AvailableDocrootLocations returns an of default docroot locations to look for.
  1146  func AvailableDocrootLocations() []string {
  1147  	return []string{
  1148  		"_www",
  1149  		"docroot",
  1150  		"htdocs",
  1151  		"html",
  1152  		"pub",
  1153  		"public",
  1154  		"web",
  1155  		"web/public",
  1156  		"webroot",
  1157  	}
  1158  }
  1159  
  1160  // DiscoverDefaultDocroot returns the default docroot directory.
  1161  func DiscoverDefaultDocroot(app *DdevApp) string {
  1162  	// Provide use the app.Docroot as the default docroot option.
  1163  	var defaultDocroot = app.Docroot
  1164  	if defaultDocroot == "" {
  1165  		for _, docroot := range AvailableDocrootLocations() {
  1166  			if _, err := os.Stat(filepath.Join(app.AppRoot, docroot)); err != nil {
  1167  				continue
  1168  			}
  1169  
  1170  			if fileutil.FileExists(filepath.Join(app.AppRoot, docroot, "index.php")) {
  1171  				defaultDocroot = docroot
  1172  				break
  1173  			}
  1174  		}
  1175  	}
  1176  	return defaultDocroot
  1177  }
  1178  
  1179  // Determine the document root.
  1180  func (app *DdevApp) docrootPrompt() error {
  1181  
  1182  	// Determine the document root.
  1183  	util.Warning("\nThe docroot is the directory from which your site is served.\nThis is a relative path from your project root at %s", app.AppRoot)
  1184  	output.UserOut.Println("You may leave this value blank if your site files are in the project root")
  1185  	var docrootPrompt = "Docroot Location"
  1186  	var defaultDocroot = DiscoverDefaultDocroot(app)
  1187  	// If there is a default docroot, display it in the prompt.
  1188  	if defaultDocroot != "" {
  1189  		docrootPrompt = fmt.Sprintf("%s (%s)", docrootPrompt, defaultDocroot)
  1190  	} else if cd, _ := os.Getwd(); cd == filepath.Join(app.AppRoot, defaultDocroot) {
  1191  		// Preserve the case where the docroot is the current directory
  1192  		docrootPrompt = fmt.Sprintf("%s (current directory)", docrootPrompt)
  1193  	} else {
  1194  		// Explicitly state 'project root' when in a subdirectory
  1195  		docrootPrompt = fmt.Sprintf("%s (project root)", docrootPrompt)
  1196  	}
  1197  
  1198  	fmt.Print(docrootPrompt + ": ")
  1199  	app.Docroot = util.GetInput(defaultDocroot)
  1200  
  1201  	// Ensure the docroot exists. If it doesn't, prompt the user to verify they entered it correctly.
  1202  	fullPath := filepath.Join(app.AppRoot, app.Docroot)
  1203  	if _, err := os.Stat(fullPath); os.IsNotExist(err) {
  1204  		util.Warning("Warning: the provided docroot at %s does not currently exist.", fullPath)
  1205  
  1206  		// Ask the user for permission to create the docroot
  1207  		if !util.Confirm(fmt.Sprintf("Create docroot at %s?", fullPath)) {
  1208  			return fmt.Errorf("docroot must exist to continue configuration")
  1209  		}
  1210  
  1211  		if err = os.MkdirAll(fullPath, 0755); err != nil {
  1212  			return fmt.Errorf("unable to create docroot: %v", err)
  1213  		}
  1214  
  1215  		util.Success("Created docroot at %s.", fullPath)
  1216  	}
  1217  
  1218  	return nil
  1219  }
  1220  
  1221  // ConfigExists determines if a ddev config file exists for this application.
  1222  func (app *DdevApp) ConfigExists() bool {
  1223  	if _, err := os.Stat(app.ConfigPath); os.IsNotExist(err) {
  1224  		return false
  1225  	}
  1226  	return true
  1227  }
  1228  
  1229  // AppTypePrompt handles the Type workflow.
  1230  func (app *DdevApp) AppTypePrompt() error {
  1231  	validAppTypes := strings.Join(GetValidAppTypes(), ", ")
  1232  	typePrompt := fmt.Sprintf("Project Type [%s]", validAppTypes)
  1233  
  1234  	// First, see if we can auto detect what kind of site it is so we can set a sane default.
  1235  
  1236  	detectedAppType := app.DetectAppType()
  1237  	// If the detected detectedAppType is php, we'll ask them to confirm,
  1238  	// otherwise go with it.
  1239  	// If we found an application type just set it and inform the user.
  1240  	util.Success("Found a %s codebase at %s.", detectedAppType, filepath.Join(app.AppRoot, app.Docroot))
  1241  	typePrompt = fmt.Sprintf("%s (%s)", typePrompt, detectedAppType)
  1242  
  1243  	fmt.Printf(typePrompt + ": ")
  1244  	appType := strings.ToLower(util.GetInput(detectedAppType))
  1245  
  1246  	for !IsValidAppType(appType) {
  1247  		output.UserOut.Errorf("'%s' is not a valid project type. Allowed project types are: %s\n", appType, validAppTypes)
  1248  
  1249  		fmt.Printf(typePrompt + ": ")
  1250  		appType = strings.ToLower(util.GetInput(appType))
  1251  	}
  1252  	app.Type = appType
  1253  	return nil
  1254  }
  1255  
  1256  // PrepDdevDirectory creates a .ddev directory in the current working directory
  1257  func PrepDdevDirectory(app *DdevApp) error {
  1258  	var err error
  1259  	dir := app.GetConfigPath("")
  1260  	if _, err := os.Stat(dir); os.IsNotExist(err) {
  1261  
  1262  		log.WithFields(log.Fields{
  1263  			"directory": dir,
  1264  		}).Debug("Config Directory does not exist, attempting to create.")
  1265  
  1266  		err = os.MkdirAll(dir, 0755)
  1267  		if err != nil {
  1268  			return err
  1269  		}
  1270  	}
  1271  
  1272  	err = os.MkdirAll(filepath.Join(dir, "web-entrypoint.d"), 0755)
  1273  	if err != nil {
  1274  		return err
  1275  	}
  1276  
  1277  	err = CreateGitIgnore(dir, "**/*.example", ".dbimageBuild", ".dbimageExtra", ".ddev-docker-*.yaml", ".*downloads", ".global_commands", ".homeadditions", ".importdb*", ".sshimageBuild", ".webimageBuild", ".webimageExtra", "apache/apache-site.conf", "commands/.gitattributes", "commands/db/mysql", "commands/host/launch", "commands/web/xdebug", "commands/web/live", "config.*.y*ml", "db_snapshots", "import-db", "import.yaml", "mutagen/mutagen.yml", "mutagen/.start-synced", "nginx_full/nginx-site.conf", "postgres/postgresql.conf", "providers/platform.yaml", "sequelpro.spf", fmt.Sprintf("traefik/config/%s.yaml", app.Name), fmt.Sprintf("traefik/certs/%s.crt", app.Name), fmt.Sprintf("traefik/certs/%s.key", app.Name), "xhprof/xhprof_prepend.php", "**/README.*")
  1278  	if err != nil {
  1279  		return fmt.Errorf("failed to create gitignore in %s: %v", dir, err)
  1280  	}
  1281  
  1282  	return nil
  1283  }
  1284  
  1285  // validateHookYAML validates command hooks and tasks defined in hooks for config.yaml
  1286  func validateHookYAML(source []byte) error {
  1287  	validHooks := []string{
  1288  		"pre-start",
  1289  		"post-start",
  1290  		"pre-import-db",
  1291  		"post-import-db",
  1292  		"pre-import-files",
  1293  		"post-import-files",
  1294  		"pre-composer",
  1295  		"post-composer",
  1296  		"pre-stop",
  1297  		"post-stop",
  1298  		"pre-config",
  1299  		"post-config",
  1300  		"pre-describe",
  1301  		"post-describe",
  1302  		"pre-exec",
  1303  		"post-exec",
  1304  		"pre-pause",
  1305  		"post-pause",
  1306  		"pre-pull",
  1307  		"post-pull",
  1308  		"pre-push",
  1309  		"post-push",
  1310  		"pre-snapshot",
  1311  		"post-snapshot",
  1312  		"pre-restore-snapshot",
  1313  		"post-restore-snapshot",
  1314  	}
  1315  
  1316  	validTasks := []string{
  1317  		"exec",
  1318  		"exec-host",
  1319  		"composer",
  1320  	}
  1321  
  1322  	type Validate struct {
  1323  		Commands map[string][]map[string]interface{} `yaml:"hooks,omitempty"`
  1324  	}
  1325  	val := &Validate{}
  1326  
  1327  	err := yaml.Unmarshal(source, val)
  1328  	if err != nil {
  1329  		return err
  1330  	}
  1331  
  1332  	for foundHook, tasks := range val.Commands {
  1333  		var match bool
  1334  		for _, h := range validHooks {
  1335  			if foundHook == h {
  1336  				match = true
  1337  			}
  1338  		}
  1339  		if !match {
  1340  			return fmt.Errorf("invalid hook %s defined in config.yaml", foundHook)
  1341  		}
  1342  
  1343  		for _, foundTask := range tasks {
  1344  			var match bool
  1345  			for _, validTaskName := range validTasks {
  1346  				if _, ok := foundTask[validTaskName]; ok {
  1347  					match = true
  1348  				}
  1349  			}
  1350  			if !match {
  1351  				return fmt.Errorf("invalid task '%s' defined for hook %s in config.yaml", foundTask, foundHook)
  1352  			}
  1353  
  1354  		}
  1355  
  1356  	}
  1357  
  1358  	return nil
  1359  }
  1360  
  1361  // IsNFSMountEnabled determines whether NFS is enabled.
  1362  // Mutagen trumps NFS, so if mutagen is enabled, NFS is not.
  1363  func (app *DdevApp) IsNFSMountEnabled() bool {
  1364  	if !app.IsMutagenEnabled() && (app.NFSMountEnabled || app.NFSMountEnabledGlobal) {
  1365  		return true
  1366  	}
  1367  	return false
  1368  }