github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/ddevapp/drupal.go (about) 1 package ddevapp 2 3 import ( 4 "fmt" 5 "os" 6 "path" 7 "path/filepath" 8 "text/template" 9 10 "github.com/ddev/ddev/pkg/archive" 11 "github.com/ddev/ddev/pkg/dockerutil" 12 "github.com/ddev/ddev/pkg/fileutil" 13 "github.com/ddev/ddev/pkg/nodeps" 14 "github.com/ddev/ddev/pkg/output" 15 "github.com/ddev/ddev/pkg/util" 16 ) 17 18 // DrupalSettings encapsulates all the configurations for a Drupal site. 19 type DrupalSettings struct { 20 DeployName string 21 DeployURL string 22 DatabaseName string 23 DatabaseUsername string 24 DatabasePassword string 25 DatabaseHost string 26 DatabaseDriver string 27 DatabasePort string 28 HashSalt string 29 Signature string 30 SitePath string 31 SiteSettings string 32 SiteSettingsDdev string 33 SyncDir string 34 DockerIP string 35 DBPublishedPort int 36 } 37 38 // NewDrupalSettings produces a DrupalSettings object with default. 39 func NewDrupalSettings(app *DdevApp) *DrupalSettings { 40 dockerIP, _ := dockerutil.GetDockerIP() 41 dbPublishedPort, _ := app.GetPublishedPort("db") 42 43 settings := &DrupalSettings{ 44 DatabaseName: "db", 45 DatabaseUsername: "db", 46 DatabasePassword: "db", 47 DatabaseHost: "db", 48 DatabaseDriver: "mysql", 49 DatabasePort: GetExposedPort(app, "db"), 50 HashSalt: util.HashSalt(app.Name), 51 Signature: nodeps.DdevFileSignature, 52 SitePath: path.Join("sites", "default"), 53 SiteSettings: "settings.php", 54 SiteSettingsDdev: "settings.ddev.php", 55 SyncDir: path.Join("files", "sync"), 56 DockerIP: dockerIP, 57 DBPublishedPort: dbPublishedPort, 58 } 59 if app.Type == "drupal6" { 60 settings.DatabaseDriver = "mysqli" 61 } 62 if app.Database.Type == nodeps.Postgres { 63 settings.DatabaseDriver = "pgsql" 64 } 65 return settings 66 } 67 68 // settingsIncludeStanza defines the template that will be appended to 69 // a project's settings.php in the event that the file already exists. 70 const settingsIncludeStanza = ` 71 // Automatically generated include for settings managed by ddev. 72 $ddev_settings = dirname(__FILE__) . '/settings.ddev.php'; 73 if (getenv('IS_DDEV_PROJECT') == 'true' && is_readable($ddev_settings)) { 74 require $ddev_settings; 75 } 76 ` 77 78 // manageDrupalSettingsFile will direct inspecting and writing of settings.php. 79 func manageDrupalSettingsFile(app *DdevApp, drupalConfig *DrupalSettings, appType string) error { 80 // We'll be writing/appending to the settings files and parent directory, make sure we have permissions to do so 81 if err := drupalEnsureWritePerms(app); err != nil { 82 return err 83 } 84 85 if !fileutil.FileExists(app.SiteSettingsPath) { 86 output.UserOut.Printf("No %s file exists, creating one", drupalConfig.SiteSettings) 87 88 if err := writeDrupalSettingsPHP(app.SiteSettingsPath, appType); err != nil { 89 return fmt.Errorf("failed to write: %v", err) 90 } 91 } 92 93 included, err := settingsHasInclude(drupalConfig, app.SiteSettingsPath) 94 if err != nil { 95 return fmt.Errorf("failed to check for include: %v", err) 96 } 97 98 if included { 99 util.Debug("Existing %s file includes %s", drupalConfig.SiteSettings, drupalConfig.SiteSettingsDdev) 100 } else { 101 util.Debug("Existing %s file does not include %s, modifying to include DDEV settings", drupalConfig.SiteSettings, drupalConfig.SiteSettingsDdev) 102 103 if err := appendIncludeToDrupalSettingsFile(app.SiteSettingsPath, app.Type); err != nil { 104 return fmt.Errorf("failed to include %s in %s: %v", drupalConfig.SiteSettingsDdev, drupalConfig.SiteSettings, err) 105 } 106 } 107 108 return nil 109 } 110 111 // writeDrupalSettingsPHP creates the project's settings.php if it doesn't exist 112 func writeDrupalSettingsPHP(filePath string, appType string) error { 113 content, err := bundledAssets.ReadFile(path.Join("drupal", appType, "settings.php")) 114 if err != nil { 115 return err 116 } 117 118 // Ensure target directory exists and is writable 119 dir := filepath.Dir(filePath) 120 if err = os.Chmod(dir, 0755); os.IsNotExist(err) { 121 if err = os.MkdirAll(dir, 0755); err != nil { 122 return err 123 } 124 } else if err != nil { 125 return err 126 } 127 128 // Create file 129 err = os.WriteFile(filePath, content, 0755) 130 if err != nil { 131 return err 132 } 133 134 return nil 135 } 136 137 // createDrupalSettingsPHP manages creation and modification of settings.php and settings.ddev.php. 138 // If a settings.php file already exists, it will be modified to ensure that it includes 139 // settings.ddev.php, which contains ddev-specific configuration. 140 func createDrupalSettingsPHP(app *DdevApp) (string, error) { 141 // Currently there isn't any customization done for the drupal config, but 142 // we may want to do some kind of customization in the future. 143 drupalConfig := NewDrupalSettings(app) 144 145 if err := manageDrupalSettingsFile(app, drupalConfig, app.Type); err != nil { 146 return "", err 147 } 148 149 if err := writeDrupalSettingsDdevPhp(drupalConfig, app.SiteDdevSettingsFile, app); err != nil { 150 return "", fmt.Errorf("`failed to write` Drupal settings file %s: %v", app.SiteDdevSettingsFile, err) 151 } 152 153 return app.SiteDdevSettingsFile, nil 154 } 155 156 // writeDrupalSettingsDdevPhp dynamically produces valid settings.ddev.php file by combining a configuration 157 // object with a data-driven template. 158 func writeDrupalSettingsDdevPhp(settings *DrupalSettings, filePath string, app *DdevApp) error { 159 if fileutil.FileExists(filePath) { 160 // Check if the file is managed by ddev. 161 signatureFound, err := fileutil.FgrepStringInFile(filePath, nodeps.DdevFileSignature) 162 if err != nil { 163 return err 164 } 165 166 // If the signature wasn't found, warn the user and return. 167 if !signatureFound { 168 util.Warning("%s already exists and is managed by the user.", filepath.Base(filePath)) 169 return nil 170 } 171 } 172 173 drupalVersion, err := GetDrupalVersion(app) 174 if err != nil || drupalVersion == "" { 175 // todo: Reconsider this logic for default version 176 drupalVersion = "10" 177 } 178 t, err := template.New("settings.ddev.php").ParseFS(bundledAssets, path.Join("drupal", "drupal"+drupalVersion, "settings.ddev.php")) 179 if err != nil { 180 return err 181 } 182 183 // Ensure target directory exists and is writable 184 dir := filepath.Dir(filePath) 185 if err = os.Chmod(dir, 0755); os.IsNotExist(err) { 186 if err = os.MkdirAll(dir, 0755); err != nil { 187 return err 188 } 189 } else if err != nil { 190 return err 191 } 192 193 file, err := os.Create(filePath) 194 if err != nil { 195 return err 196 } 197 err = t.Execute(file, settings) 198 if err != nil { 199 return err 200 } 201 util.CheckClose(file) 202 return nil 203 } 204 205 // WriteDrushrc writes out drushrc.php based on passed-in values. 206 // This works on Drupal 6 and Drupal 7 or with drush8 and older 207 func WriteDrushrc(app *DdevApp, filePath string) error { 208 if fileutil.FileExists(filePath) { 209 // Check if the file is managed by ddev. 210 signatureFound, err := fileutil.FgrepStringInFile(filePath, nodeps.DdevFileSignature) 211 if err != nil { 212 return err 213 } 214 215 // If the signature wasn't found, warn the user and return. 216 if !signatureFound { 217 util.Warning("%s already exists and is managed by the user.", filepath.Base(filePath)) 218 return nil 219 } 220 } 221 222 uri := app.GetPrimaryURL() 223 drushContents := []byte(`<?php 224 225 /** 226 * @file 227 * ` + nodeps.DdevFileSignature + `: Automatically generated drushrc.php file (for Drush 8) 228 * DDEV manages this file and may delete or overwrite it unless this comment is removed. 229 * Remove this comment if you don't want DDEV to manage this file. 230 */ 231 232 if (getenv('IS_DDEV_PROJECT') == 'true') { 233 $options['l'] = "` + uri + `"; 234 } 235 `) 236 237 // Ensure target directory exists and is writable 238 dir := filepath.Dir(filePath) 239 if err := os.Chmod(dir, 0755); os.IsNotExist(err) { 240 if err = os.MkdirAll(dir, 0755); err != nil { 241 return err 242 } 243 } else if err != nil { 244 return err 245 } 246 247 err := os.WriteFile(filePath, drushContents, 0666) 248 if err != nil { 249 return err 250 } 251 252 return nil 253 } 254 255 // getDrupalUploadDirs will return the default paths. 256 func getDrupalUploadDirs(_ *DdevApp) []string { 257 uploadDirs := []string{"sites/default/files"} 258 259 return uploadDirs 260 } 261 262 // DrupalHooks adds d8+-specific hooks example for post-import-db 263 const DrupalHooks = `# post-import-db: 264 # - exec: drush sql:sanitize 265 # - exec: drush updatedb 266 # - exec: drush cache:rebuild 267 ` 268 269 // Drupal7Hooks adds a d7-specific hooks example for post-import-db 270 const Drupal7Hooks = `# post-import-db: 271 # - exec: drush cc all 272 ` 273 274 // getDrupal7Hooks for appending as byte array 275 func getDrupal7Hooks() []byte { 276 return []byte(Drupal7Hooks) 277 } 278 279 // getDrupal6Hooks for appending as byte array 280 func getDrupal6Hooks() []byte { 281 // We don't have anything new to add yet, so use Drupal7 version 282 return []byte(Drupal7Hooks) 283 } 284 285 // getDrupalHooks for appending as byte array 286 func getDrupalHooks() []byte { 287 return []byte(DrupalHooks) 288 } 289 290 // setDrupalSiteSettingsPaths sets the paths to settings.php/settings.ddev.php 291 // for templating. 292 func setDrupalSiteSettingsPaths(app *DdevApp) { 293 drupalConfig := NewDrupalSettings(app) 294 settingsFileBasePath := filepath.Join(app.AppRoot, app.Docroot) 295 app.SiteSettingsPath = filepath.Join(settingsFileBasePath, drupalConfig.SitePath, drupalConfig.SiteSettings) 296 app.SiteDdevSettingsFile = filepath.Join(settingsFileBasePath, drupalConfig.SitePath, drupalConfig.SiteSettingsDdev) 297 } 298 299 // isDrupal7App returns true if the app is of type drupal7 300 func isDrupal7App(app *DdevApp) bool { 301 if _, err := os.Stat(filepath.Join(app.AppRoot, app.Docroot, "misc/ajax.js")); err == nil { 302 return true 303 } 304 return false 305 } 306 307 // GetDrupalVersion finds the drupal8+ version so it can be used 308 // for setting requirements. 309 // It can only work if there is configured Drupal8+ code 310 func GetDrupalVersion(app *DdevApp) (string, error) { 311 // For drupal6/7 we use the apptype provided as version 312 switch app.Type { 313 case nodeps.AppTypeDrupal6: 314 return "6", nil 315 case nodeps.AppTypeDrupal7: 316 return "7", nil 317 } 318 // Otherwise figure out the version from existing code 319 f := filepath.Join(app.AppRoot, app.Docroot, "core/lib/Drupal.php") 320 hasVersion, matches, err := fileutil.GrepStringInFile(f, `const VERSION = '([0-9]+)`) 321 v := "" 322 if hasVersion { 323 v = matches[1] 324 } 325 return v, err 326 } 327 328 // isDrupalApp returns true if the app is drupal 329 func isDrupalApp(app *DdevApp) bool { 330 v, err := GetDrupalVersion(app) 331 if err == nil && v != "" { 332 return true 333 } 334 return false 335 } 336 337 // isDrupal6App returns true if the app is of type Drupal6 338 func isDrupal6App(app *DdevApp) bool { 339 if _, err := os.Stat(filepath.Join(app.AppRoot, app.Docroot, "misc/ahah.js")); err == nil { 340 return true 341 } 342 return false 343 } 344 345 // drupal6ConfigOverrideAction overrides php_version for D6 346 func drupal6ConfigOverrideAction(app *DdevApp) error { 347 app.PHPVersion = nodeps.PHP56 348 return nil 349 } 350 351 // drupal7ConfigOverrideAction overrides php_version for D7 352 func drupal7ConfigOverrideAction(app *DdevApp) error { 353 app.PHPVersion = nodeps.PHP82 354 return nil 355 } 356 357 // drupalConfigOverrideAction selects proper versions for 358 func drupalConfigOverrideAction(app *DdevApp) error { 359 v, err := GetDrupalVersion(app) 360 if err != nil || v == "" { 361 util.Warning("Unable to detect Drupal version, continuing") 362 return nil 363 } 364 // If there is no database, update it to the default one, 365 // otherwise show a warning to the user. 366 if !nodeps.ArrayContainsString(app.GetOmittedContainers(), "db") { 367 if dbType, err := app.GetExistingDBType(); err == nil && dbType == "" { 368 app.Database = DatabaseDefault 369 } else if app.Database != DatabaseDefault && v != "8" { 370 defaultType := DatabaseDefault.Type + ":" + DatabaseDefault.Version 371 util.Warning("Default database type is %s, but the current actual database type is %s, you may want to migrate with 'ddev debug migrate-database %s'.", defaultType, dbType, defaultType) 372 } 373 } 374 switch v { 375 case "8": 376 app.PHPVersion = nodeps.PHP74 377 app.Database = DatabaseDesc{Type: nodeps.MariaDB, Version: nodeps.MariaDB104} 378 case "9": 379 app.PHPVersion = nodeps.PHP81 380 case "10": 381 app.PHPVersion = nodeps.PHP83 382 case "11": 383 app.PHPVersion = nodeps.PHP83 384 app.CorepackEnable = true 385 } 386 return nil 387 } 388 389 func drupalPostStartAction(app *DdevApp) error { 390 if !nodeps.ArrayContainsString(app.GetOmittedContainers(), "db") && (isDrupalApp(app)) { 391 err := app.Wait([]string{nodeps.DBContainer}) 392 if err != nil { 393 return err 394 } 395 // pg_trm extension is required in Drupal9.5+ 396 if app.Database.Type == nodeps.Postgres { 397 stdout, stderr, err := app.Exec(&ExecOpts{ 398 Service: "db", 399 Cmd: `psql -q -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" 2>/dev/null`, 400 NoCapture: false, 401 }) 402 if err != nil { 403 util.Warning("unable to CREATE EXTENSION pg_trm: stdout='%s', stderr='%s', err=%v", stdout, stderr, err) 404 } 405 } 406 // SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED required in Drupal 9.5+ 407 if app.Database.Type == nodeps.MariaDB || app.Database.Type == nodeps.MySQL { 408 _, _, err := app.Exec(&ExecOpts{ 409 Service: "db", 410 Cmd: `mysql -uroot -proot -e "SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;" >/dev/null 2>&1`, 411 NoCapture: false, 412 }) 413 if err != nil { 414 util.Warning("Unable to SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED: %v", err) 415 } 416 } 417 } 418 // Return early because we aren't expected to manage settings. 419 if app.DisableSettingsManagement { 420 return nil 421 } 422 if err := createDrupal8SyncDir(app); err != nil { 423 return err 424 } 425 426 //nolint: revive 427 if err := drupalEnsureWritePerms(app); err != nil { 428 return err 429 } 430 return nil 431 } 432 433 // drupal7PostStartAction handles default post-start actions for D7 apps, like ensuring 434 // useful permissions settings on sites/default. 435 func drupal7PostStartAction(app *DdevApp) error { 436 // Return early because we aren't expected to manage settings. 437 if app.DisableSettingsManagement { 438 return nil 439 } 440 if err := drupalEnsureWritePerms(app); err != nil { 441 return err 442 } 443 444 err := WriteDrushrc(app, filepath.Join(filepath.Dir(app.SiteSettingsPath), "drushrc.php")) 445 if err != nil { 446 util.Warning("Failed to WriteDrushrc: %v", err) 447 } 448 449 return nil 450 } 451 452 // drupal6PostStartAction handles default post-start actions for D6 apps, like ensuring 453 // useful permissions settings on sites/default. 454 func drupal6PostStartAction(app *DdevApp) error { 455 // Return early because we aren't expected to manage settings. 456 if app.DisableSettingsManagement { 457 return nil 458 } 459 460 if err := drupalEnsureWritePerms(app); err != nil { 461 return err 462 } 463 464 err := WriteDrushrc(app, filepath.Join(filepath.Dir(app.SiteSettingsPath), "drushrc.php")) 465 if err != nil { 466 util.Warning("Failed to WriteDrushrc: %v", err) 467 } 468 return nil 469 } 470 471 // drupalEnsureWritePerms will ensure sites/default and sites/default/settings.php will 472 // have the appropriate permissions for development. 473 func drupalEnsureWritePerms(app *DdevApp) error { 474 util.Debug("Ensuring write permissions for %s", app.GetName()) 475 var writePerms os.FileMode = 0200 476 477 settingsDir := path.Dir(app.SiteSettingsPath) 478 makeWritable := []string{ 479 settingsDir, 480 app.SiteSettingsPath, 481 app.SiteDdevSettingsFile, 482 path.Join(settingsDir, "services.yml"), 483 } 484 485 for _, o := range makeWritable { 486 stat, err := os.Stat(o) 487 if err != nil { 488 if !os.IsNotExist(err) { 489 util.Warning("Unable to ensure write permissions: %v", err) 490 } 491 492 continue 493 } 494 495 if err := os.Chmod(o, stat.Mode()|writePerms); err != nil { 496 // Warn the user, but continue. 497 util.Warning("Unable to set permissions: %v", err) 498 } 499 } 500 501 return nil 502 } 503 504 // createDrupal8SyncDir creates a Drupal 8 app's sync directory 505 func createDrupal8SyncDir(app *DdevApp) error { 506 // Currently there isn't any customization done for the drupal config, but 507 // we may want to do some kind of customization in the future. 508 drupalConfig := NewDrupalSettings(app) 509 510 syncDirPath := path.Join(app.GetAppRoot(), app.GetDocroot(), "sites/default", drupalConfig.SyncDir) 511 if fileutil.FileExists(syncDirPath) { 512 return nil 513 } 514 515 if err := os.MkdirAll(syncDirPath, 0755); err != nil { 516 return fmt.Errorf("failed to create sync directory (%s): %v", syncDirPath, err) 517 } 518 519 return nil 520 } 521 522 // settingsHasInclude determines if the settings.php or equivalent includes settings.ddev.php or equivalent. 523 // This is done by looking for the DDEV settings file (settings.ddev.php) in settings.php. 524 func settingsHasInclude(drupalConfig *DrupalSettings, siteSettingsPath string) (bool, error) { 525 included, err := fileutil.FgrepStringInFile(siteSettingsPath, drupalConfig.SiteSettingsDdev) 526 if err != nil { 527 return false, err 528 } 529 530 return included, nil 531 } 532 533 // appendIncludeToDrupalSettingsFile modifies the settings.php file to include the settings.ddev.php 534 // file, which contains ddev-specific configuration. 535 func appendIncludeToDrupalSettingsFile(siteSettingsPath string, appType string) error { 536 // Check if file is empty 537 contents, err := os.ReadFile(siteSettingsPath) 538 if err != nil { 539 return err 540 } 541 542 // If the file is empty, write the complete settings file and return 543 if len(contents) == 0 { 544 return writeDrupalSettingsPHP(siteSettingsPath, appType) 545 } 546 547 // The file is not empty, open it for appending 548 file, err := os.OpenFile(siteSettingsPath, os.O_RDWR|os.O_APPEND, 0644) 549 if err != nil { 550 return err 551 } 552 defer util.CheckClose(file) 553 554 _, err = file.Write([]byte(settingsIncludeStanza)) 555 if err != nil { 556 return err 557 } 558 return nil 559 } 560 561 // drupalImportFilesAction defines the Drupal workflow for importing project files. 562 func drupalImportFilesAction(app *DdevApp, uploadDir, importPath, extPath string) error { 563 destPath := app.calculateHostUploadDirFullPath(uploadDir) 564 565 // Parent of destination dir should exist 566 if !fileutil.FileExists(filepath.Dir(destPath)) { 567 return fmt.Errorf("unable to import to %s: parent directory does not exist", destPath) 568 } 569 570 // Parent of destination dir should be writable. 571 if err := os.Chmod(filepath.Dir(destPath), 0755); err != nil { 572 return err 573 } 574 575 // If the destination path exists, remove it as was warned 576 if fileutil.FileExists(destPath) { 577 if err := os.RemoveAll(destPath); err != nil { 578 return fmt.Errorf("failed to cleanup %s before import: %v", destPath, err) 579 } 580 } 581 582 if isTar(importPath) { 583 if err := archive.Untar(importPath, destPath, extPath); err != nil { 584 return fmt.Errorf("failed to extract provided archive: %v", err) 585 } 586 587 return nil 588 } 589 590 if isZip(importPath) { 591 if err := archive.Unzip(importPath, destPath, extPath); err != nil { 592 return fmt.Errorf("failed to extract provided archive: %v", err) 593 } 594 595 return nil 596 } 597 598 //nolint: revive 599 if err := fileutil.CopyDir(importPath, destPath); err != nil { 600 return err 601 } 602 603 return nil 604 } 605 606 // getDrupalComposerCreateAllowedPaths returns fullpaths that are allowed to be present when running composer create 607 func getDrupalComposerCreateAllowedPaths(app *DdevApp) ([]string, error) { 608 var allowed []string 609 610 // Return early because we aren't expected to manage settings. 611 if app.DisableSettingsManagement { 612 return []string{}, nil 613 } 614 615 drupalConfig := NewDrupalSettings(app) 616 617 if app.Type == "drupal6" || app.Type == "drupal7" { 618 // drushrc.php path 619 allowed = append(allowed, filepath.Join(filepath.Dir(app.SiteSettingsPath), "drushrc.php")) 620 } else { 621 // Sync path 622 allowed = append(allowed, path.Join(app.GetDocroot(), "sites/default", drupalConfig.SyncDir)) 623 } 624 625 return allowed, nil 626 }