github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/ddevapp/config.go (about)

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