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 }