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

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