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

     1  package ddevapp
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"path"
     7  	"path/filepath"
     8  	"sort"
     9  	"strings"
    10  
    11  	"github.com/ddev/ddev/pkg/nodeps"
    12  	"github.com/ddev/ddev/pkg/util"
    13  	"github.com/maruel/natural"
    14  	"github.com/pkg/errors"
    15  )
    16  
    17  // appTypeFuncs prototypes
    18  //
    19  // settingsCreator
    20  type settingsCreator func(*DdevApp) (string, error)
    21  
    22  // uploadDirs
    23  type uploadDirs func(*DdevApp) []string
    24  
    25  // hookDefaultComments should probably change its arg from string to app when
    26  // config refactor is done.
    27  type hookDefaultComments func() []byte
    28  
    29  // composerCreateAllowedPaths
    30  type composerCreateAllowedPaths func(app *DdevApp) ([]string, error)
    31  
    32  // appTypeSettingsPaths
    33  type appTypeSettingsPaths func(app *DdevApp)
    34  
    35  // appTypeDetect returns true if the app is of the specified type
    36  type appTypeDetect func(app *DdevApp) bool
    37  
    38  // postImportDBAction can take actions after import (like warning user about
    39  // required actions on Wordpress.
    40  type postImportDBAction func(app *DdevApp) error
    41  
    42  // configOverrideAction allows a particular apptype to override elements
    43  // of the config for that apptype. Key example is drupal6 needing php56
    44  type configOverrideAction func(app *DdevApp) error
    45  
    46  // postConfigAction allows actions to take place at the end of ddev config
    47  type postConfigAction func(app *DdevApp) error
    48  
    49  // postStartAction allows actions to take place at the end of ddev start
    50  type postStartAction func(app *DdevApp) error
    51  
    52  // importFilesAction
    53  type importFilesAction func(app *DdevApp, uploadDir, importPath, extractPath string) error
    54  
    55  // defaultWorkingDirMap returns the app type's default working directory map
    56  type defaultWorkingDirMap func(app *DdevApp, defaults map[string]string) map[string]string
    57  
    58  // appTypeFuncs struct defines the functions that can be called (if populated)
    59  // for a given appType.
    60  type appTypeFuncs struct {
    61  	settingsCreator
    62  	uploadDirs
    63  	hookDefaultComments
    64  	composerCreateAllowedPaths
    65  	appTypeSettingsPaths
    66  	appTypeDetect
    67  	postImportDBAction
    68  	configOverrideAction
    69  	postConfigAction
    70  	postStartAction
    71  	importFilesAction
    72  	defaultWorkingDirMap
    73  }
    74  
    75  // appTypeMatrix is a static map that defines the various functions to be called
    76  // for each apptype (CMS).
    77  var appTypeMatrix map[string]appTypeFuncs
    78  
    79  func init() {
    80  	appTypeMatrix = map[string]appTypeFuncs{
    81  		nodeps.AppTypeBackdrop: {
    82  			settingsCreator:            createBackdropSettingsFile,
    83  			uploadDirs:                 getBackdropUploadDirs,
    84  			hookDefaultComments:        getBackdropHooks,
    85  			appTypeSettingsPaths:       setBackdropSiteSettingsPaths,
    86  			appTypeDetect:              isBackdropApp,
    87  			postImportDBAction:         backdropPostImportDBAction,
    88  			postStartAction:            backdropPostStartAction,
    89  			importFilesAction:          backdropImportFilesAction,
    90  			defaultWorkingDirMap:       docrootWorkingDir,
    91  			composerCreateAllowedPaths: getBackdropComposerCreateAllowedPaths,
    92  		},
    93  
    94  		nodeps.AppTypeCakePHP: {
    95  			appTypeDetect:        isCakephpApp,
    96  			configOverrideAction: cakephpConfigOverrideAction,
    97  			postStartAction:      cakephpPostStartAction,
    98  		},
    99  
   100  		nodeps.AppTypeCraftCms: {
   101  			importFilesAction:    craftCmsImportFilesAction,
   102  			appTypeDetect:        isCraftCmsApp,
   103  			configOverrideAction: craftCmsConfigOverrideAction,
   104  			postStartAction:      craftCmsPostStartAction,
   105  		},
   106  
   107  		nodeps.AppTypeDjango4: {
   108  			settingsCreator:      django4SettingsCreator,
   109  			appTypeDetect:        isDjango4App,
   110  			configOverrideAction: django4ConfigOverrideAction,
   111  			postConfigAction:     django4PostConfigAction,
   112  			postStartAction:      django4PostStartAction,
   113  		},
   114  
   115  		nodeps.AppTypeDrupal6: {
   116  			settingsCreator:            createDrupalSettingsPHP,
   117  			uploadDirs:                 getDrupalUploadDirs,
   118  			hookDefaultComments:        getDrupal6Hooks,
   119  			appTypeSettingsPaths:       setDrupalSiteSettingsPaths,
   120  			appTypeDetect:              isDrupal6App,
   121  			configOverrideAction:       drupal6ConfigOverrideAction,
   122  			postStartAction:            drupal6PostStartAction,
   123  			importFilesAction:          drupalImportFilesAction,
   124  			defaultWorkingDirMap:       docrootWorkingDir,
   125  			composerCreateAllowedPaths: getDrupalComposerCreateAllowedPaths,
   126  		},
   127  
   128  		nodeps.AppTypeDrupal7: {
   129  			settingsCreator:            createDrupalSettingsPHP,
   130  			uploadDirs:                 getDrupalUploadDirs,
   131  			hookDefaultComments:        getDrupal7Hooks,
   132  			appTypeSettingsPaths:       setDrupalSiteSettingsPaths,
   133  			appTypeDetect:              isDrupal7App,
   134  			configOverrideAction:       drupal7ConfigOverrideAction,
   135  			postStartAction:            drupal7PostStartAction,
   136  			importFilesAction:          drupalImportFilesAction,
   137  			defaultWorkingDirMap:       docrootWorkingDir,
   138  			composerCreateAllowedPaths: getDrupalComposerCreateAllowedPaths,
   139  		},
   140  
   141  		nodeps.AppTypeDrupal: {
   142  			settingsCreator:            createDrupalSettingsPHP,
   143  			uploadDirs:                 getDrupalUploadDirs,
   144  			hookDefaultComments:        getDrupalHooks,
   145  			appTypeSettingsPaths:       setDrupalSiteSettingsPaths,
   146  			appTypeDetect:              isDrupalApp,
   147  			configOverrideAction:       drupalConfigOverrideAction,
   148  			postStartAction:            drupalPostStartAction,
   149  			importFilesAction:          drupalImportFilesAction,
   150  			composerCreateAllowedPaths: getDrupalComposerCreateAllowedPaths,
   151  		},
   152  
   153  		nodeps.AppTypeLaravel: {
   154  			appTypeDetect:   isLaravelApp,
   155  			postStartAction: laravelPostStartAction,
   156  		},
   157  
   158  		nodeps.AppTypeSilverstripe: {
   159  			appTypeDetect:        isSilverstripeApp,
   160  			postStartAction:      silverstripePostStartAction,
   161  			configOverrideAction: silverstripeConfigOverrideAction,
   162  			uploadDirs:           getSilverstripeUploadDirs,
   163  		},
   164  
   165  		nodeps.AppTypeMagento: {
   166  			settingsCreator:      createMagentoSettingsFile,
   167  			uploadDirs:           getMagentoUploadDirs,
   168  			appTypeSettingsPaths: setMagentoSiteSettingsPaths,
   169  			appTypeDetect:        isMagentoApp,
   170  			importFilesAction:    magentoImportFilesAction,
   171  		},
   172  
   173  		nodeps.AppTypeMagento2: {
   174  			settingsCreator:      createMagento2SettingsFile,
   175  			uploadDirs:           getMagento2UploadDirs,
   176  			appTypeSettingsPaths: setMagento2SiteSettingsPaths,
   177  			appTypeDetect:        isMagento2App,
   178  			configOverrideAction: magento2ConfigOverrideAction,
   179  			importFilesAction:    magentoImportFilesAction,
   180  		},
   181  
   182  		nodeps.AppTypePHP: {
   183  			postStartAction: nil,
   184  		},
   185  
   186  		nodeps.AppTypePython: {
   187  			appTypeDetect:        isPythonApp,
   188  			configOverrideAction: pythonConfigOverrideAction,
   189  			postConfigAction:     pythonPostConfigAction,
   190  		},
   191  
   192  		nodeps.AppTypeShopware6: {
   193  			appTypeDetect:        isShopware6App,
   194  			appTypeSettingsPaths: setShopware6SiteSettingsPaths,
   195  			uploadDirs:           getShopwareUploadDirs,
   196  			postStartAction:      shopware6PostStartAction,
   197  			importFilesAction:    shopware6ImportFilesAction,
   198  		},
   199  
   200  		nodeps.AppTypeTYPO3: {
   201  			settingsCreator:      createTypo3SettingsFile,
   202  			uploadDirs:           getTypo3UploadDirs,
   203  			hookDefaultComments:  getTypo3Hooks,
   204  			appTypeSettingsPaths: setTypo3SiteSettingsPaths,
   205  			appTypeDetect:        isTypo3App,
   206  			importFilesAction:    typo3ImportFilesAction,
   207  		},
   208  
   209  		nodeps.AppTypeWordPress: {
   210  			settingsCreator:      createWordpressSettingsFile,
   211  			uploadDirs:           getWordpressUploadDirs,
   212  			hookDefaultComments:  getWordpressHooks,
   213  			appTypeSettingsPaths: setWordpressSiteSettingsPaths,
   214  			appTypeDetect:        isWordpressApp,
   215  			importFilesAction:    wordpressImportFilesAction,
   216  		},
   217  	}
   218  
   219  	drupalAlias := appTypeMatrix[nodeps.AppTypeDrupal]
   220  	drupalAlias.appTypeDetect = nil
   221  	for _, alias := range []string{nodeps.AppTypeDrupal8, nodeps.AppTypeDrupal9, nodeps.AppTypeDrupal10} {
   222  		appTypeMatrix[alias] = drupalAlias
   223  	}
   224  }
   225  
   226  // CreateSettingsFile creates the settings file (like settings.php) for the
   227  // provided app is the apptype has a settingsCreator function.
   228  // It also preps the ddev directory, including setting up the .ddev gitignore
   229  func (app *DdevApp) CreateSettingsFile() (string, error) {
   230  	err := PrepDdevDirectory(app)
   231  	if err != nil {
   232  		util.Warning("Unable to PrepDdevDirectory: %v", err)
   233  	}
   234  
   235  	app.SetApptypeSettingsPaths()
   236  
   237  	if app.DisableSettingsManagement && app.Type != nodeps.AppTypePHP {
   238  		util.Warning("Not creating CMS settings files because disable_settings_management=true")
   239  		return "", nil
   240  	}
   241  
   242  	// Drupal and WordPress love to change settings files to be unwriteable.
   243  	// Chmod them to something we can work with in the event that they already
   244  	// exist.
   245  	if app.SiteSettingsPath != "" {
   246  		chmodTargets := []string{filepath.Dir(app.SiteSettingsPath), app.SiteDdevSettingsFile}
   247  		for _, fp := range chmodTargets {
   248  			fileInfo, err := os.Stat(fp)
   249  			if err != nil {
   250  				// We're not doing anything about this error other than warning,
   251  				// and will have to deal with the same check in settingsCreator.
   252  				if !os.IsNotExist(err) {
   253  					util.Warning("Unable to ensure write permissions: %v", err)
   254  				}
   255  
   256  				continue
   257  			}
   258  
   259  			perms := 0644
   260  			if fileInfo.IsDir() {
   261  				perms = 0755
   262  			}
   263  
   264  			err = os.Chmod(fp, os.FileMode(perms))
   265  			if err != nil {
   266  				return "", fmt.Errorf("could not change permissions on file %s to make it writeable: %v", fp, err)
   267  			}
   268  		}
   269  	}
   270  
   271  	// If we have a function to do the settings creation, do it, otherwise
   272  	// ignore it.
   273  	if appFuncs, ok := appTypeMatrix[app.GetType()]; ok && appFuncs.settingsCreator != nil {
   274  		settingsPath, err := appFuncs.settingsCreator(app)
   275  		if err != nil {
   276  			util.Warning("Unable to create settings file '%s': %v", app.SiteSettingsPath, err)
   277  		}
   278  
   279  		// Don't create gitignore if it would be in top-level directory, where
   280  		// there is almost certainly already a gitignore (like Backdrop)
   281  		if path.Dir(app.SiteSettingsPath) != app.AppRoot {
   282  			if err = CreateGitIgnore(filepath.Dir(app.SiteSettingsPath), filepath.Base(app.SiteDdevSettingsFile), "drushrc.php"); err != nil {
   283  				util.Warning("Failed to write .gitignore in %s: %v", filepath.Dir(app.SiteDdevSettingsFile), err)
   284  			}
   285  		}
   286  		return settingsPath, nil
   287  	}
   288  
   289  	// If the project is not running, it makes no sense to sync it
   290  	if s, _ := app.SiteStatus(); s == SiteRunning {
   291  		err = app.MutagenSyncFlush()
   292  		if err != nil {
   293  			return "", err
   294  		}
   295  	}
   296  
   297  	return "", nil
   298  }
   299  
   300  // GetHookDefaultComments gets the actual text of the config.yaml hook suggestions
   301  // for a given apptype
   302  func (app *DdevApp) GetHookDefaultComments() []byte {
   303  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.hookDefaultComments != nil {
   304  		suggestions := appFuncs.hookDefaultComments()
   305  		return suggestions
   306  	}
   307  	return []byte("")
   308  }
   309  
   310  // GetComposerCreateAllowedPaths gets all paths relative to the app root that are allowed to be present
   311  // for a given apptype when running ddev composer create
   312  func (app *DdevApp) GetComposerCreateAllowedPaths() ([]string, error) {
   313  	var allowed []string
   314  
   315  	// doc root
   316  	allowed = append(allowed, nodeps.PathWithSlashesToArray(app.GetRelativeDirectory(app.GetDocroot()))...)
   317  
   318  	// composer root
   319  	allowed = append(allowed, nodeps.PathWithSlashesToArray(app.GetRelativeDirectory(app.GetComposerRoot(false, false)))...)
   320  
   321  	// allow upload dirs
   322  	// upload dirs are probably always relative and with slashes, but we run
   323  	// it through GetRelativeDirectory() just in case.
   324  	uploadDirs := app.getUploadDirsRelative()
   325  	for _, uploadDir := range uploadDirs {
   326  		allowed = append(allowed, nodeps.PathWithSlashesToArray(app.GetRelativeDirectory(uploadDir))...)
   327  	}
   328  
   329  	// Settings files
   330  	allowed = append(allowed, nodeps.PathWithSlashesToArray(app.GetRelativeDirectory(app.SiteSettingsPath))...)
   331  	allowed = append(allowed, nodeps.PathWithSlashesToArray(app.GetRelativeDirectory(app.SiteDdevSettingsFile))...)
   332  
   333  	// If we have a function to do the settings creation, allow .gitignore
   334  	// see CreateSettingsFile
   335  	if appFuncs, ok := appTypeMatrix[app.GetType()]; ok && appFuncs.settingsCreator != nil {
   336  		// We don't create gitignore if it would be in top-level directory, where
   337  		// there is almost certainly already a gitignore (like Backdrop)
   338  		if path.Dir(app.SiteSettingsPath) != app.AppRoot {
   339  			allowed = append(allowed, nodeps.PathWithSlashesToArray(app.GetRelativeDirectory(filepath.Join(filepath.Dir(app.SiteSettingsPath), ".gitignore")))...)
   340  		}
   341  	}
   342  
   343  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.composerCreateAllowedPaths != nil {
   344  		paths, err := appFuncs.composerCreateAllowedPaths(app)
   345  		if err != nil {
   346  			return []string{""}, err
   347  		}
   348  		for _, path := range paths {
   349  			allowed = append(allowed, nodeps.PathWithSlashesToArray(app.GetRelativeDirectory(path))...)
   350  		}
   351  	}
   352  	allowed = util.SliceToUniqueSlice(&allowed)
   353  	sort.Strings(allowed)
   354  	return allowed, nil
   355  }
   356  
   357  // SetApptypeSettingsPaths chooses and sets the settings.php/settings.local.php
   358  // and related paths for a given app.
   359  func (app *DdevApp) SetApptypeSettingsPaths() {
   360  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.appTypeSettingsPaths != nil {
   361  		appFuncs.appTypeSettingsPaths(app)
   362  	}
   363  }
   364  
   365  // DetectAppType calls each apptype's detector until it finds a match,
   366  // or returns 'php' as a last resort.
   367  func (app *DdevApp) DetectAppType() string {
   368  	for appTypeName, appFuncs := range appTypeMatrix {
   369  		if appFuncs.appTypeDetect != nil && appFuncs.appTypeDetect(app) {
   370  			return appTypeName
   371  		}
   372  	}
   373  
   374  	return nodeps.AppTypePHP
   375  }
   376  
   377  // PostImportDBAction calls each apptype's detector until it finds a match,
   378  // or returns 'php' as a last resort.
   379  func (app *DdevApp) PostImportDBAction() error {
   380  
   381  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.postImportDBAction != nil {
   382  		return appFuncs.postImportDBAction(app)
   383  	}
   384  
   385  	return nil
   386  }
   387  
   388  // ConfigFileOverrideAction gives a chance for an apptype to override any element
   389  // of config.yaml that it needs to
   390  func (app *DdevApp) ConfigFileOverrideAction(overrideExistingConfig bool) error {
   391  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.configOverrideAction != nil && (overrideExistingConfig || !app.ConfigExists()) {
   392  		origDB := app.Database
   393  		err := appFuncs.configOverrideAction(app)
   394  		if err != nil {
   395  			return err
   396  		}
   397  		// If the override function has changed the database type
   398  		// check to make sure that there's not one already existing
   399  		if origDB != app.Database {
   400  			// We can't upgrade database if it already exists
   401  			dbType, err := app.GetExistingDBType()
   402  			if err != nil {
   403  				return err
   404  			}
   405  			recommendedDBType := app.Database.Type + ":" + app.Database.Version
   406  			if dbType == "" {
   407  				// Assume that we don't have a database yet
   408  				util.Success("Configuring %s project with database type '%s'", app.Type, recommendedDBType)
   409  			} else if dbType != recommendedDBType {
   410  				util.Warning("%s project already has database type set to non-recommended: %s, not changing it to recommended %s", app.Type, dbType, recommendedDBType)
   411  				app.Database = origDB
   412  			}
   413  		}
   414  	}
   415  
   416  	return nil
   417  }
   418  
   419  // PostConfigAction gives a chance for an apptype to override do something at
   420  // the end of ddev config.
   421  func (app *DdevApp) PostConfigAction() error {
   422  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.postConfigAction != nil {
   423  		return appFuncs.postConfigAction(app)
   424  	}
   425  
   426  	return nil
   427  }
   428  
   429  // PostStartAction gives a chance for an apptype to do something after the app
   430  // has been started.
   431  func (app *DdevApp) PostStartAction() error {
   432  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.postStartAction != nil {
   433  		return appFuncs.postStartAction(app)
   434  	}
   435  
   436  	return nil
   437  }
   438  
   439  // dispatchImportFilesAction executes the relevant import files workflow for each app type.
   440  func (app *DdevApp) dispatchImportFilesAction(uploadDir, importPath, extractPath string) error {
   441  	if strings.TrimSpace(uploadDir) == "" {
   442  		return errors.Errorf("upload_dirs is not set for this project (%s)", app.Type)
   443  	}
   444  
   445  	if appFuncs, ok := appTypeMatrix[app.Type]; ok {
   446  		// if a specific action is not defined, use a generic action
   447  		if appFuncs.importFilesAction == nil {
   448  			appFuncs.importFilesAction = genericImportFilesAction
   449  		}
   450  		return appFuncs.importFilesAction(app, uploadDir, importPath, extractPath)
   451  	}
   452  
   453  	return fmt.Errorf("this project type (%s) does not support import-files", app.Type)
   454  }
   455  
   456  // DefaultWorkingDirMap returns the app type's default working directory map.
   457  func (app *DdevApp) DefaultWorkingDirMap() map[string]string {
   458  	_, _, username := util.GetContainerUIDGid()
   459  	// Default working directory values are defined here.
   460  	// Services working directories can be overridden by app types if needed.
   461  	defaults := map[string]string{
   462  		"web": "/var/www/html/",
   463  		"db":  "/home/" + username,
   464  	}
   465  
   466  	if appFuncs, ok := appTypeMatrix[app.Type]; ok && appFuncs.defaultWorkingDirMap != nil {
   467  		return appFuncs.defaultWorkingDirMap(app, defaults)
   468  	}
   469  
   470  	if app.Database.Type == nodeps.Postgres {
   471  		defaults["db"] = "/var/lib/postgresql"
   472  	}
   473  	return defaults
   474  }
   475  
   476  // docrootWorkingDir handles the shared case in which the web service working directory is the docroot.
   477  func docrootWorkingDir(app *DdevApp, defaults map[string]string) map[string]string {
   478  	defaults["web"] = path.Join("/var/www/html", app.Docroot)
   479  
   480  	return defaults
   481  }
   482  
   483  // IsValidAppType is a helper function to determine if an app type is valid, returning
   484  // true if the given app type is valid and configured and false otherwise.
   485  func IsValidAppType(apptype string) bool {
   486  	if _, ok := appTypeMatrix[apptype]; !ok {
   487  		return false
   488  	}
   489  
   490  	return true
   491  }
   492  
   493  // GetValidAppTypes returns the valid apptype keys from the appTypeMatrix
   494  func GetValidAppTypes() []string {
   495  	keys := make([]string, 0, len(appTypeMatrix))
   496  	for k := range appTypeMatrix {
   497  		keys = append(keys, k)
   498  		sort.Sort(natural.StringSlice(keys))
   499  	}
   500  	return keys
   501  }
   502  
   503  // GetValidAppTypesWithoutAliases returns the valid apptype keys from the appTypeMatrix without aliases like
   504  // drupal8/9/10
   505  func GetValidAppTypesWithoutAliases() []string {
   506  	keys := make([]string, 0, len(appTypeMatrix))
   507  	for k := range appTypeMatrix {
   508  		if k == nodeps.AppTypeDrupal8 || k == nodeps.AppTypeDrupal9 || k == nodeps.AppTypeDrupal10 {
   509  			continue
   510  		}
   511  		keys = append(keys, k)
   512  	}
   513  	sort.Strings(keys)
   514  	return keys
   515  }