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

     1  package ddevapp
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"path"
     7  	"path/filepath"
     8  	"slices"
     9  	"sort"
    10  	"strings"
    11  	"text/template"
    12  
    13  	"github.com/Masterminds/sprig/v3"
    14  	"github.com/ddev/ddev/pkg/dockerutil"
    15  	"github.com/ddev/ddev/pkg/fileutil"
    16  	"github.com/ddev/ddev/pkg/globalconfig"
    17  	"github.com/ddev/ddev/pkg/nodeps"
    18  	"github.com/ddev/ddev/pkg/output"
    19  	"github.com/ddev/ddev/pkg/util"
    20  	dockerContainer "github.com/docker/docker/api/types/container"
    21  	dockerVersions "github.com/docker/docker/api/types/versions"
    22  	"github.com/jedib0t/go-pretty/v6/table"
    23  	"github.com/spf13/cobra"
    24  )
    25  
    26  // GetActiveProjects returns an array of DDEV projects
    27  // that are currently live in docker.
    28  func GetActiveProjects() []*DdevApp {
    29  	apps := make([]*DdevApp, 0)
    30  	labels := map[string]string{
    31  		"com.ddev.platform":          "ddev",
    32  		"com.docker.compose.service": "web",
    33  	}
    34  	containers, err := dockerutil.FindContainersByLabels(labels)
    35  
    36  	if err == nil {
    37  		for _, siteContainer := range containers {
    38  			approot, ok := siteContainer.Labels["com.ddev.approot"]
    39  			if !ok {
    40  				break
    41  			}
    42  
    43  			app, err := NewApp(approot, true)
    44  
    45  			// Artificially populate sitename and apptype based on labels
    46  			// if NewApp() failed.
    47  			if err != nil {
    48  				app.Name = siteContainer.Labels["com.ddev.site-name"]
    49  				app.Type = siteContainer.Labels["com.ddev.app-type"]
    50  				app.AppRoot = siteContainer.Labels["com.ddev.approot"]
    51  			}
    52  			apps = append(apps, app)
    53  		}
    54  	}
    55  
    56  	return apps
    57  }
    58  
    59  // RenderHomeRootedDir shortens a directory name to replace homedir with ~
    60  func RenderHomeRootedDir(path string) string {
    61  	userDir, err := os.UserHomeDir()
    62  	util.CheckErr(err)
    63  	result := strings.Replace(path, userDir, "~", 1)
    64  	result = strings.Replace(result, "\\", "/", -1)
    65  	return result
    66  }
    67  
    68  // RenderAppRow will add an application row to an existing table for describe and list output.
    69  func RenderAppRow(t table.Writer, row map[string]interface{}) {
    70  	status := fmt.Sprint(row["status_desc"])
    71  	urls := ""
    72  	mutagenStatus := ""
    73  	if row["status"] == SiteRunning {
    74  		urls = row["primary_url"].(string)
    75  		if row["mutagen_enabled"] == true {
    76  			if _, ok := row["mutagen_status"]; ok {
    77  				mutagenStatus = row["mutagen_status"].(string)
    78  			} else {
    79  				mutagenStatus = "not enabled"
    80  			}
    81  			if mutagenStatus != "ok" {
    82  				mutagenStatus = util.ColorizeText(mutagenStatus, "red")
    83  			}
    84  			status = fmt.Sprintf("%s (%s)", status, mutagenStatus)
    85  		}
    86  	}
    87  
    88  	status = FormatSiteStatus(status)
    89  
    90  	t.AppendRow(table.Row{
    91  		row["name"], status, row["shortroot"], urls, row["type"],
    92  	})
    93  
    94  }
    95  
    96  // Cleanup will remove DDEV containers and volumes even if docker-compose.yml
    97  // has been deleted.
    98  func Cleanup(app *DdevApp) error {
    99  	ctx, client := dockerutil.GetDockerClient()
   100  
   101  	// Find all containers which match the current site name.
   102  	labels := map[string]string{
   103  		"com.ddev.site-name": app.GetName(),
   104  	}
   105  
   106  	// remove project network
   107  	// "docker-compose down" - removes project network and any left-overs
   108  	// There can be awkward cases where we're doing an app.Stop() but the rendered
   109  	// yaml does not exist, all in testing situations.
   110  	if fileutil.FileExists(app.DockerComposeFullRenderedYAMLPath()) {
   111  		_, _, err := dockerutil.ComposeCmd(&dockerutil.ComposeCmdOpts{
   112  			ComposeFiles: []string{app.DockerComposeFullRenderedYAMLPath()},
   113  			Action:       []string{"down"},
   114  		})
   115  		if err != nil {
   116  			util.Warning("Failed to docker-compose down: %v", err)
   117  		}
   118  	}
   119  
   120  	// If any leftovers or lost souls, find them as well
   121  	containers, err := dockerutil.FindContainersByLabels(labels)
   122  	if err != nil {
   123  		return err
   124  	}
   125  	// First, try stopping the listed containers if they are running.
   126  	for i := range containers {
   127  		containerName := containers[i].Names[0][1:len(containers[i].Names[0])]
   128  		removeOpts := dockerContainer.RemoveOptions{
   129  			RemoveVolumes: true,
   130  			Force:         true,
   131  		}
   132  		output.UserOut.Printf("Removing container: %s", containerName)
   133  		if err = client.ContainerRemove(ctx, containers[i].ID, removeOpts); err != nil {
   134  			return fmt.Errorf("could not remove container %s: %v", containerName, err)
   135  		}
   136  	}
   137  	// Always kill the temporary volumes on ddev remove
   138  	vols := []string{app.GetNFSMountVolumeName(), "ddev-" + app.Name + "-snapshots", app.Name + "-ddev-config"}
   139  
   140  	for _, volName := range vols {
   141  		_ = dockerutil.RemoveVolume(volName)
   142  	}
   143  
   144  	err = StopRouterIfNoContainers()
   145  	return err
   146  }
   147  
   148  // CheckForConf checks for a config.yaml at the cwd or parent dirs.
   149  func CheckForConf(confPath string) (string, error) {
   150  	if fileutil.FileExists(filepath.Join(confPath, ".ddev", "config.yaml")) {
   151  		return confPath, nil
   152  	}
   153  
   154  	// Keep going until we can't go any higher
   155  	for filepath.Dir(confPath) != confPath {
   156  		confPath = filepath.Dir(confPath)
   157  		if fileutil.FileExists(filepath.Join(confPath, ".ddev", "config.yaml")) {
   158  			return confPath, nil
   159  		}
   160  	}
   161  
   162  	return "", fmt.Errorf("no %s file was found in this directory or any parent", filepath.Join(".ddev", "config.yaml"))
   163  }
   164  
   165  // ddevContainersRunning determines if any ddev-controlled containers are currently running.
   166  func ddevContainersRunning() (bool, error) {
   167  	labels := map[string]string{
   168  		"com.ddev.platform": "ddev",
   169  	}
   170  	containers, err := dockerutil.FindContainersByLabels(labels)
   171  	if err != nil {
   172  		return false, err
   173  	}
   174  
   175  	for _, container := range containers {
   176  		if _, ok := container.Labels["com.ddev.platform"]; ok {
   177  			return true, nil
   178  		}
   179  	}
   180  	return false, nil
   181  }
   182  
   183  // getTemplateFuncMap will return a map of useful template functions.
   184  func getTemplateFuncMap() map[string]interface{} {
   185  	// Use sprig's template function map as a base
   186  	m := sprig.FuncMap()
   187  
   188  	// Add helpful utilities on top of it
   189  	m["joinPath"] = path.Join
   190  	m["templateCanUse"] = templateCanUse
   191  
   192  	return m
   193  }
   194  
   195  // templateCanUse will return true if the given feature is available.
   196  // This is used in YAML templates to determine whether to use a feature or not.
   197  func templateCanUse(feature string) bool {
   198  	// healthcheck.start_interval requires Docker Engine v25 or later
   199  	// See https://github.com/docker/compose/pull/10939
   200  	if feature == "healthcheck.start_interval" {
   201  		dockerAPIVersion, err := dockerutil.GetDockerAPIVersion()
   202  		if err != nil {
   203  			return false
   204  		}
   205  		return dockerVersions.GreaterThanOrEqualTo(dockerAPIVersion, "1.44")
   206  	}
   207  	return false
   208  }
   209  
   210  // gitIgnoreTemplate will write a .gitignore file.
   211  // This template expects string slice to be provided, with each string corresponding to
   212  // a line in the resulting .gitignore.
   213  const gitIgnoreTemplate = `{{.Signature}}: Automatically generated ddev .gitignore.
   214  # You can remove the above line if you want to edit and maintain this file yourself.
   215  /.gitignore
   216  {{range .IgnoredItems}}
   217  /{{.}}{{end}}
   218  `
   219  
   220  type ignoreTemplateContents struct {
   221  	Signature    string
   222  	IgnoredItems []string
   223  }
   224  
   225  // CreateGitIgnore will create a .gitignore file in the target directory if one does not exist.
   226  // Each value in ignores will be added as a new line to the .gitignore.
   227  func CreateGitIgnore(targetDir string, ignores ...string) error {
   228  	gitIgnoreFilePath := filepath.Join(targetDir, ".gitignore")
   229  
   230  	if fileutil.FileExists(gitIgnoreFilePath) {
   231  		sigFound, err := fileutil.FgrepStringInFile(gitIgnoreFilePath, nodeps.DdevFileSignature)
   232  		if err != nil {
   233  			return err
   234  		}
   235  
   236  		// If we sigFound the file and did not find the signature in .ddev/.gitignore, warn about it.
   237  		if !sigFound {
   238  			util.Warning("User-managed %s will not be managed/overwritten by ddev", gitIgnoreFilePath)
   239  			return nil
   240  		}
   241  		// Otherwise, remove the existing file to prevent surprising template results
   242  		err = os.Remove(gitIgnoreFilePath)
   243  		if err != nil {
   244  			return err
   245  		}
   246  	}
   247  	err := os.MkdirAll(targetDir, 0777)
   248  	if err != nil {
   249  		return err
   250  	}
   251  
   252  	generatedIgnores := []string{}
   253  	for _, p := range ignores {
   254  		sigFound, err := fileutil.FgrepStringInFile(p, nodeps.DdevFileSignature)
   255  		if sigFound || err != nil {
   256  			generatedIgnores = append(generatedIgnores, p)
   257  		}
   258  	}
   259  
   260  	tmpl, err := template.New("gitignore").Funcs(getTemplateFuncMap()).Parse(gitIgnoreTemplate)
   261  	if err != nil {
   262  		return err
   263  	}
   264  
   265  	file, err := os.OpenFile(gitIgnoreFilePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
   266  	if err != nil {
   267  		return err
   268  	}
   269  	defer util.CheckClose(file)
   270  
   271  	parms := ignoreTemplateContents{
   272  		Signature:    nodeps.DdevFileSignature,
   273  		IgnoredItems: generatedIgnores,
   274  	}
   275  
   276  	//nolint: revive
   277  	if err = tmpl.Execute(file, parms); err != nil {
   278  		return err
   279  	}
   280  
   281  	return nil
   282  }
   283  
   284  // isTar determines whether the object at the filepath is a .tar archive.
   285  func isTar(filepath string) bool {
   286  	tarSuffixes := []string{".tar", ".tar.gz", ".tar.bz2", ".tar.xz", ".tgz"}
   287  	for _, suffix := range tarSuffixes {
   288  		if strings.HasSuffix(filepath, suffix) {
   289  			return true
   290  		}
   291  	}
   292  
   293  	return false
   294  }
   295  
   296  // isZip determines if the object at hte filepath is a .zip.
   297  func isZip(filepath string) bool {
   298  	return strings.HasSuffix(filepath, ".zip")
   299  }
   300  
   301  // GetErrLogsFromApp is used to do app.Logs on an app after an error has
   302  // been received, especially on app.Start. This is really for testing only
   303  // returns logs, healthcheck history, error
   304  func GetErrLogsFromApp(app *DdevApp, errorReceived error) (string, string, error) {
   305  	var serviceName string
   306  	if errorReceived == nil {
   307  		return "no error detected", "", nil
   308  	}
   309  	errString := errorReceived.Error()
   310  	errString = strings.Replace(errString, "Received unexpected error:", "", -1)
   311  	errString = strings.Replace(errString, "\n", "", -1)
   312  	errString = strings.Trim(errString, " \t\n\r")
   313  	if strings.Contains(errString, "container failed") || strings.Contains(errString, "container did not become ready") || strings.Contains(errString, "failed to become ready") {
   314  		splitError := strings.Split(errString, " ")
   315  		if len(splitError) > 0 && nodeps.ArrayContainsString([]string{"web", "db", "ddev-router", "ddev-ssh-agent"}, splitError[0]) {
   316  			serviceName = splitError[0]
   317  			health := ""
   318  			if containerID, err := dockerutil.FindContainerByName(serviceName); err == nil {
   319  				_, health = dockerutil.GetContainerHealth(containerID)
   320  			}
   321  			logs, err := app.CaptureLogs(serviceName, false, "10")
   322  			if err != nil {
   323  				return "", "", err
   324  			}
   325  			return logs, health, nil
   326  		}
   327  	}
   328  	return "", "", fmt.Errorf("no logs found for service %s (Inspected err=%v)", serviceName, errorReceived)
   329  }
   330  
   331  // CheckForMissingProjectFiles returns an error if the project's configuration or project root cannot be found
   332  func CheckForMissingProjectFiles(project *DdevApp) error {
   333  	status, _ := project.SiteStatus()
   334  	if status == SiteConfigMissing || status == SiteDirMissing {
   335  		return fmt.Errorf("ddev can no longer find your project files at %s. If you would like to continue using DDEV to manage this project please restore your files to that directory. If you would like to make DDEV forget this project, you may run 'ddev stop --unlist %s'", project.GetAppRoot(), project.GetName())
   336  	}
   337  
   338  	return nil
   339  }
   340  
   341  // GetProjects returns projects that are listed
   342  // in globalconfig projectlist (or in Docker container labels, or both)
   343  // if activeOnly is true, only show projects that aren't stopped
   344  // (or broken, missing config, missing files)
   345  func GetProjects(activeOnly bool) ([]*DdevApp, error) {
   346  	apps := make(map[string]*DdevApp)
   347  	projectList := globalconfig.GetGlobalProjectList()
   348  
   349  	// First grab the GetActiveApps (Docker labels) version of the projects and make sure it's
   350  	// included. Hopefully Docker label information and global config information will not
   351  	// be out of sync very often.
   352  	dockerActiveApps := GetActiveProjects()
   353  	for _, app := range dockerActiveApps {
   354  		apps[app.Name] = app
   355  	}
   356  
   357  	// Now get everything we can find in global project list
   358  	for name, info := range projectList {
   359  		// Skip apps already found running in Docker
   360  		if _, ok := apps[name]; ok {
   361  			continue
   362  		}
   363  
   364  		app, err := NewApp(info.AppRoot, true)
   365  		if err != nil {
   366  			if os.IsNotExist(err) {
   367  				util.Warning("The project '%s' no longer exists in the filesystem, removing it from registry", info.AppRoot)
   368  				err = globalconfig.RemoveProjectInfo(name)
   369  				if err != nil {
   370  					util.Warning("unable to RemoveProjectInfo(%s): %v", name, err)
   371  				}
   372  			} else {
   373  				util.Warning("Something went wrong with %s: %v", info.AppRoot, err)
   374  			}
   375  			continue
   376  		}
   377  
   378  		// If the app we loaded was already found with a different name, complain
   379  		if _, ok := apps[app.Name]; ok {
   380  			util.Warning(`Project '%s' was found in configured directory %s and it is already used by project '%s'. If you have changed the name of the project, please "ddev stop --unlist %s" `, app.Name, app.AppRoot, name, name)
   381  			continue
   382  		}
   383  
   384  		status, _ := app.SiteStatus()
   385  		if !activeOnly || (status != SiteStopped && status != SiteConfigMissing && status != SiteDirMissing) {
   386  			apps[app.Name] = app
   387  		}
   388  	}
   389  
   390  	appSlice := []*DdevApp{}
   391  	for _, v := range apps {
   392  		appSlice = append(appSlice, v)
   393  	}
   394  	sort.Slice(appSlice, func(i, j int) bool { return appSlice[i].Name < appSlice[j].Name })
   395  
   396  	return appSlice, nil
   397  }
   398  
   399  // GetInactiveProjects returns projects that are currently running
   400  func GetInactiveProjects() ([]*DdevApp, error) {
   401  	var inactiveApps []*DdevApp
   402  
   403  	apps, err := GetProjects(false)
   404  
   405  	if err != nil {
   406  		return nil, err
   407  	}
   408  
   409  	for _, app := range apps {
   410  		status, _ := app.SiteStatus()
   411  		if status != SiteRunning {
   412  			inactiveApps = append(inactiveApps, app)
   413  		}
   414  	}
   415  
   416  	return inactiveApps, nil
   417  }
   418  
   419  // ExtractProjectNames returns a list of names by a bunch of projects
   420  func ExtractProjectNames(apps []*DdevApp) []string {
   421  	var names []string
   422  	for _, app := range apps {
   423  		names = append(names, app.Name)
   424  	}
   425  
   426  	return names
   427  }
   428  
   429  // GetProjectNamesFunc returns a function for autocompleting project names
   430  // for command arguments.
   431  // If status is "inactive" or "active", only names of inactive or active
   432  // projects respectively are returned.
   433  // If status is "all", all project names are returned.
   434  // If numArgs is 0, completion will be provided for infinite arguments,
   435  // otherwise it will only be provided for the numArgs number of arguments.
   436  func GetProjectNamesFunc(status string, numArgs int) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
   437  	return func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
   438  		// Don't provide completions if the user keeps hitting space after
   439  		// exhausting all of the valid arguments.
   440  		if numArgs > 0 && len(args)+1 > numArgs {
   441  			return nil, cobra.ShellCompDirectiveNoFileComp
   442  		}
   443  
   444  		// Get all of the projects we're interested in for this completion function.
   445  		var apps []*DdevApp
   446  		var err error
   447  		if status == "inactive" {
   448  			apps, err = GetInactiveProjects()
   449  		} else if status == "active" {
   450  			apps, err = GetProjects(true)
   451  		} else if status == "all" {
   452  			apps, err = GetProjects(false)
   453  		} else {
   454  			// This is an error state - but we just return nothing
   455  			return nil, cobra.ShellCompDirectiveNoFileComp
   456  		}
   457  		// Return nothing if we have nothing, or return all of the project names.
   458  		// Note that if there's nothing to return, we don't let cobra pick completions
   459  		// from the files in the cwd.
   460  		if err != nil {
   461  			return nil, cobra.ShellCompDirectiveNoFileComp
   462  		}
   463  
   464  		// Don't show arguments that are already written on the command line
   465  		var projectNames []string
   466  		for _, name := range ExtractProjectNames(apps) {
   467  			if !slices.Contains(args, name) {
   468  				projectNames = append(projectNames, name)
   469  			}
   470  		}
   471  		return projectNames, cobra.ShellCompDirectiveNoFileComp
   472  	}
   473  }
   474  
   475  // GetRelativeDirectory returns the directory relative to project root
   476  // Note that the relative dir is returned as unix-style forward-slashes
   477  func (app *DdevApp) GetRelativeDirectory(path string) string {
   478  	// Find the relative dir
   479  	relativeWorkingDir := strings.TrimPrefix(path, app.AppRoot)
   480  	// Convert to slash/linux/macos notation, should work everywhere
   481  	relativeWorkingDir = filepath.ToSlash(relativeWorkingDir)
   482  	// Remove any leading /
   483  	relativeWorkingDir = strings.TrimLeft(relativeWorkingDir, "/")
   484  
   485  	return relativeWorkingDir
   486  }
   487  
   488  // GetRelativeWorkingDirectory returns the relative working directory relative to project root
   489  // Note that the relative dir is returned as unix-style forward-slashes
   490  func (app *DdevApp) GetRelativeWorkingDirectory() string {
   491  	pwd, _ := os.Getwd()
   492  	return app.GetRelativeDirectory(pwd)
   493  }
   494  
   495  // HasCustomCert returns true if the project uses a custom certificate
   496  func (app *DdevApp) HasCustomCert() bool {
   497  	customCertsPath := app.GetConfigPath("custom_certs")
   498  	certFileName := fmt.Sprintf("%s.crt", app.Name)
   499  	if !globalconfig.DdevGlobalConfig.IsTraefikRouter() {
   500  		certFileName = fmt.Sprintf("%s.crt", app.GetHostname())
   501  	}
   502  	return fileutil.FileExists(filepath.Join(customCertsPath, certFileName))
   503  }
   504  
   505  // CanUseHTTPOnly returns true if the project can be accessed via http only
   506  func (app *DdevApp) CanUseHTTPOnly() bool {
   507  	switch {
   508  	// Gitpod and Codespaces have their own router with TLS termination
   509  	case nodeps.IsGitpod() || nodeps.IsCodespaces():
   510  		return false
   511  	// If we have no router, then no https otherwise
   512  	case IsRouterDisabled(app):
   513  		return true
   514  	// If a custom cert, we can do https, so false
   515  	case app.HasCustomCert():
   516  		return false
   517  	// If no mkcert installed, no https
   518  	case globalconfig.GetCAROOT() == "":
   519  		return true
   520  	}
   521  	// Default case is OK to use https
   522  	return false
   523  }
   524  
   525  // Turn a slice of *DdevApp into a map keyed by name
   526  func AppSliceToMap(appList []*DdevApp) map[string]*DdevApp {
   527  	nameMap := make(map[string]*DdevApp)
   528  	for _, app := range appList {
   529  		nameMap[app.Name] = app
   530  	}
   531  	return nameMap
   532  }