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 }