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

     1  package ddevapp
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"os"
     7  	"path"
     8  	"path/filepath"
     9  	"regexp"
    10  	"sort"
    11  	"strings"
    12  	"text/template"
    13  
    14  	ddevImages "github.com/ddev/ddev/pkg/docker"
    15  	"github.com/ddev/ddev/pkg/dockerutil"
    16  	"github.com/ddev/ddev/pkg/globalconfig"
    17  	"github.com/ddev/ddev/pkg/netutil"
    18  	"github.com/ddev/ddev/pkg/nodeps"
    19  	"github.com/ddev/ddev/pkg/util"
    20  	dockerTypes "github.com/docker/docker/api/types"
    21  )
    22  
    23  // RouterProjectName is the "machine name" of the router docker-compose
    24  const RouterProjectName = "ddev-router"
    25  
    26  // RouterComposeYAMLPath returns the full filepath to the routers docker-compose yaml file.
    27  func RouterComposeYAMLPath() string {
    28  	globalDir := globalconfig.GetGlobalDdevDir()
    29  	dest := path.Join(globalDir, ".router-compose.yaml")
    30  	return dest
    31  }
    32  
    33  // FullRenderedRouterComposeYAMLPath returns the path of the full rendered .router-compose-full.yaml
    34  func FullRenderedRouterComposeYAMLPath() string {
    35  	globalDir := globalconfig.GetGlobalDdevDir()
    36  	dest := path.Join(globalDir, ".router-compose-full.yaml")
    37  	return dest
    38  }
    39  
    40  // IsRouterDisabled returns true if the router is disabled
    41  func IsRouterDisabled(app *DdevApp) bool {
    42  	if nodeps.IsGitpod() || nodeps.IsCodespaces() {
    43  		return true
    44  	}
    45  	return nodeps.ArrayContainsString(app.GetOmittedContainers(), globalconfig.DdevRouterContainer)
    46  }
    47  
    48  // StopRouterIfNoContainers stops the router if there are no DDEV containers running.
    49  func StopRouterIfNoContainers() error {
    50  	containersRunning, err := ddevContainersRunning()
    51  	if err != nil {
    52  		return err
    53  	}
    54  
    55  	if !containersRunning {
    56  		err = dockerutil.RemoveContainer(nodeps.RouterContainer)
    57  		if err != nil {
    58  			if ok := dockerutil.IsErrNotFound(err); !ok {
    59  				return err
    60  			}
    61  		}
    62  	}
    63  	return nil
    64  }
    65  
    66  // StartDdevRouter ensures the router is running.
    67  func StartDdevRouter() error {
    68  	// If the router is not healthy/running, we'll kill it so it
    69  	// starts over again.
    70  	router, err := FindDdevRouter()
    71  	if router != nil && err == nil && router.State != "running" {
    72  		err = dockerutil.RemoveContainer(nodeps.RouterContainer)
    73  		if err != nil {
    74  			return err
    75  		}
    76  	}
    77  
    78  	routerComposeFullPath, err := generateRouterCompose()
    79  	if err != nil {
    80  		return err
    81  	}
    82  	if globalconfig.DdevGlobalConfig.IsTraefikRouter() {
    83  		err = pushGlobalTraefikConfig()
    84  		if err != nil {
    85  			return fmt.Errorf("failed to push global Traefik config: %v", err)
    86  		}
    87  	}
    88  
    89  	err = CheckRouterPorts()
    90  	if err != nil {
    91  		return fmt.Errorf("unable to listen on required ports, %v,\nTroubleshooting suggestions at https://ddev.readthedocs.io/en/stable/users/usage/troubleshooting/#unable-listen", err)
    92  	}
    93  
    94  	// Run docker-compose up -d against the ddev-router full compose file
    95  	_, _, err = dockerutil.ComposeCmd(&dockerutil.ComposeCmdOpts{
    96  		ComposeFiles: []string{routerComposeFullPath},
    97  		Action:       []string{"-p", RouterProjectName, "up", "--build", "-d"},
    98  	})
    99  	if err != nil {
   100  		return fmt.Errorf("failed to start ddev-router: %v", err)
   101  	}
   102  
   103  	// Ensure we have a happy router
   104  	label := map[string]string{"com.docker.compose.service": "ddev-router"}
   105  	// Normally the router comes right up, but when
   106  	// it has to do let's encrypt updates, it can take
   107  	// some time.
   108  	routerWaitTimeout := 60
   109  	if globalconfig.DdevGlobalConfig.UseLetsEncrypt {
   110  		routerWaitTimeout = 180
   111  	}
   112  	util.Debug(`Waiting for ddev-router to become ready. docker inspect --format "{{json .State.Health }}" ddev-router`)
   113  	logOutput, err := dockerutil.ContainerWait(routerWaitTimeout, label)
   114  	if err != nil {
   115  		return fmt.Errorf("ddev-router failed to become ready; debug with 'docker logs ddev-router' and 'docker inspect --format \"{{json .State.Health }}\" ddev-router'; logOutput=%s, err=%v", logOutput, err)
   116  	}
   117  	util.Debug("ddev-router is ready")
   118  
   119  	return nil
   120  }
   121  
   122  // generateRouterCompose() generates the ~/.ddev/.router-compose.yaml and ~/.ddev/.router-compose-full.yaml
   123  func generateRouterCompose() (string, error) {
   124  	exposedPorts := determineRouterPorts()
   125  
   126  	routerComposeBasePath := RouterComposeYAMLPath()
   127  	routerComposeFullPath := FullRenderedRouterComposeYAMLPath()
   128  
   129  	var doc bytes.Buffer
   130  	f, ferr := os.Create(routerComposeBasePath)
   131  	if ferr != nil {
   132  		return "", ferr
   133  	}
   134  	defer util.CheckClose(f)
   135  
   136  	dockerIP, _ := dockerutil.GetDockerIP()
   137  
   138  	uid, gid, username := util.GetContainerUIDGid()
   139  
   140  	templateVars := map[string]interface{}{
   141  		"Username":                   username,
   142  		"UID":                        uid,
   143  		"GID":                        gid,
   144  		"router_image":               ddevImages.GetRouterImage(),
   145  		"ports":                      exposedPorts,
   146  		"router_bind_all_interfaces": globalconfig.DdevGlobalConfig.RouterBindAllInterfaces,
   147  		"dockerIP":                   dockerIP,
   148  		"disable_http2":              globalconfig.DdevGlobalConfig.DisableHTTP2,
   149  		"letsencrypt":                globalconfig.DdevGlobalConfig.UseLetsEncrypt,
   150  		"letsencrypt_email":          globalconfig.DdevGlobalConfig.LetsEncryptEmail,
   151  		"Router":                     globalconfig.DdevGlobalConfig.Router,
   152  		"TraefikMonitorPort":         globalconfig.DdevGlobalConfig.TraefikMonitorPort,
   153  	}
   154  
   155  	t, err := template.New("router_compose_template.yaml").ParseFS(bundledAssets, "router_compose_template.yaml")
   156  	if err != nil {
   157  		return "", err
   158  	}
   159  
   160  	err = t.Execute(&doc, templateVars)
   161  	if err != nil {
   162  		return "", err
   163  	}
   164  	_, err = f.WriteString(doc.String())
   165  	if err != nil {
   166  		return "", err
   167  	}
   168  
   169  	fullHandle, err := os.Create(routerComposeFullPath)
   170  	if err != nil {
   171  		return "", err
   172  	}
   173  
   174  	userFiles, err := filepath.Glob(filepath.Join(globalconfig.GetGlobalDdevDir(), "router-compose.*.yaml"))
   175  	if err != nil {
   176  		return "", err
   177  	}
   178  	files := append([]string{RouterComposeYAMLPath()}, userFiles...)
   179  	fullContents, _, err := dockerutil.ComposeCmd(&dockerutil.ComposeCmdOpts{
   180  		ComposeFiles: files,
   181  		Action:       []string{"config"},
   182  	})
   183  	if err != nil {
   184  		return "", err
   185  	}
   186  	_, err = fullHandle.WriteString(fullContents)
   187  	if err != nil {
   188  		return "", err
   189  	}
   190  
   191  	return routerComposeFullPath, nil
   192  }
   193  
   194  // FindDdevRouter uses FindContainerByLabels to get our router container and
   195  // return it.
   196  func FindDdevRouter() (*dockerTypes.Container, error) {
   197  	containerQuery := map[string]string{
   198  		"com.docker.compose.service": RouterProjectName,
   199  	}
   200  	container, err := dockerutil.FindContainerByLabels(containerQuery)
   201  	if err != nil {
   202  		return nil, fmt.Errorf("failed to execute findContainersByLabels, %v", err)
   203  	}
   204  	if container == nil {
   205  		return nil, fmt.Errorf("no ddev-router was found")
   206  	}
   207  	return container, nil
   208  }
   209  
   210  // RenderRouterStatus returns a user-friendly string showing router-status
   211  func RenderRouterStatus() string {
   212  	var renderedStatus string
   213  	if !nodeps.ArrayContainsString(globalconfig.DdevGlobalConfig.OmitContainersGlobal, globalconfig.DdevRouterContainer) {
   214  		status, logOutput := GetRouterStatus()
   215  		badRouter := "The router is not healthy. Your projects may not be accessible.\nIf it doesn't become healthy try running 'ddev poweroff && ddev start' on a project to recreate it."
   216  
   217  		switch status {
   218  		case SiteStopped:
   219  			renderedStatus = util.ColorizeText(status, "red") + " " + badRouter
   220  		case "healthy":
   221  			renderedStatus = util.ColorizeText(status, "green")
   222  		case "exited":
   223  			fallthrough
   224  		default:
   225  			renderedStatus = util.ColorizeText(status, "red") + " " + badRouter + "\n" + logOutput
   226  		}
   227  	}
   228  	return renderedStatus
   229  }
   230  
   231  // GetRouterStatus returns router status and warning if not
   232  // running or healthy, as applicable.
   233  // return status and most recent log
   234  func GetRouterStatus() (string, string) {
   235  	var status, logOutput string
   236  	container, err := FindDdevRouter()
   237  
   238  	if err != nil || container == nil {
   239  		status = SiteStopped
   240  	} else {
   241  		status, logOutput = dockerutil.GetContainerHealth(container)
   242  	}
   243  
   244  	return status, logOutput
   245  }
   246  
   247  // determineRouterPorts returns a list of port mappings retrieved from running site
   248  // containers defining VIRTUAL_PORT env var
   249  func determineRouterPorts() []string {
   250  	var routerPorts []string
   251  	containers, err := dockerutil.FindContainersWithLabel("com.ddev.site-name")
   252  	if err != nil {
   253  		util.Failed("Failed to retrieve containers for determining port mappings: %v", err)
   254  	}
   255  
   256  	// Loop through all containers with site-name label
   257  	for _, container := range containers {
   258  		if _, ok := container.Labels["com.ddev.site-name"]; ok {
   259  			if container.State != "running" {
   260  				continue
   261  			}
   262  			var exposePorts []string
   263  
   264  			httpPorts := dockerutil.GetContainerEnv("HTTP_EXPOSE", container)
   265  			if httpPorts != "" {
   266  				ports := strings.Split(httpPorts, ",")
   267  				exposePorts = append(exposePorts, ports...)
   268  			}
   269  
   270  			httpsPorts := dockerutil.GetContainerEnv("HTTPS_EXPOSE", container)
   271  			if httpsPorts != "" {
   272  				ports := strings.Split(httpsPorts, ",")
   273  				exposePorts = append(exposePorts, ports...)
   274  			}
   275  
   276  			for _, exposePortPair := range exposePorts {
   277  				// Ports defined as hostPort:containerPort allow for router to configure upstreams
   278  				// for containerPort, with server listening on hostPort. exposed ports for router
   279  				// should be hostPort:hostPort so router can determine what port a request came from
   280  				// and route the request to the correct upstream
   281  				exposePort := ""
   282  				var ports []string
   283  
   284  				// Each port pair should be of the form <number>:<number> or <number>
   285  				// It's possible to have received a malformed HTTP_EXPOSE or HTTPS_EXPOSE from
   286  				// some random container, so don't break if that happens.
   287  				if !regexp.MustCompile(`^[0-9]+(:[0-9]+)?$`).MatchString(exposePortPair) {
   288  					continue
   289  				}
   290  
   291  				if strings.Contains(exposePortPair, ":") {
   292  					ports = strings.Split(exposePortPair, ":")
   293  				} else {
   294  					// HTTP_EXPOSE and HTTPS_EXPOSE can be a single port, meaning port:port
   295  					ports = []string{exposePortPair, exposePortPair}
   296  				}
   297  				exposePort = ports[0]
   298  
   299  				var match bool
   300  				for _, routerPort := range routerPorts {
   301  					if exposePort == routerPort {
   302  						match = true
   303  					}
   304  				}
   305  
   306  				// If no match, we are adding a new port mapping
   307  				if !match {
   308  					routerPorts = append(routerPorts, exposePort)
   309  				}
   310  			}
   311  		}
   312  	}
   313  	sort.Slice(routerPorts, func(i, j int) bool {
   314  		return routerPorts[i] < routerPorts[j]
   315  	})
   316  
   317  	return routerPorts
   318  }
   319  
   320  // CheckRouterPorts tries to connect to the ports the router will use as a heuristic to find out
   321  // if they're available for docker to bind to. Returns an error if either one results
   322  // in a successful connection.
   323  func CheckRouterPorts() error {
   324  	routerContainer, _ := FindDdevRouter()
   325  	var existingExposedPorts []string
   326  	var err error
   327  	if routerContainer != nil {
   328  		existingExposedPorts, err = dockerutil.GetExposedContainerPorts(routerContainer.ID)
   329  		if err != nil {
   330  			return err
   331  		}
   332  	}
   333  	newRouterPorts := determineRouterPorts()
   334  
   335  	for _, port := range newRouterPorts {
   336  		if nodeps.ArrayContainsString(existingExposedPorts, port) {
   337  			continue
   338  		}
   339  		if netutil.IsPortActive(port) {
   340  			return fmt.Errorf("port %s is already in use", port)
   341  		}
   342  	}
   343  	return nil
   344  }