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 }