package api import ( "fmt" "net" "strconv" "strings" "werkdock/internal/store" ) // A quirk is the per-image knowledge werkdock needs because two of // docker's promises are not werkdock's to keep (RFC 0005): // // - There is no network namespace, so a published port cannot be // mapped. The service must be told to bind the port the client is // to be given, which every serious image allows through an // environment variable. // - There is one uid. An image whose entrypoint starts as root and // drops privileges itself (the official postgres image and its // gosu dance) has to be started as the target user right away; // those entrypoints detect a non-root uid and skip the dance. // // This is a deliberate deviation from docker, documented in RFC 0005 // and in the CHANGELOG. An image that is not listed runs as the image // says and keeps its port, which is correct as long as one instance of // it runs at a time. type quirk struct { // User is what to run as when neither the request nor the image // names a user. User string // PortEnv maps a container port to the environment variable that // moves the service to another port. PortEnv map[int]string } var quirks = map[string]quirk{ "postgres": {User: "postgres", PortEnv: map[int]string{5432: "PGPORT"}}, "mariadb": {User: "mysql", PortEnv: map[int]string{3306: "MARIADB_TCP_PORT"}}, "mysql": {User: "mysql", PortEnv: map[int]string{3306: "MYSQL_TCP_PORT"}}, "redis": {PortEnv: map[int]string{6379: "REDIS_PORT"}}, } // quirkFor looks the table up by the image's bare repository name, so // postgres, library/postgres and docker.io/library/postgres:16-alpine // are the same image. func quirkFor(reference string) quirk { ref, err := store.ParseReference(reference) if err != nil { return quirk{} } repo := ref.Repository if i := strings.LastIndex(repo, "/"); i >= 0 { repo = repo[i+1:] } return quirks[repo] } // port is one published port and where it actually listens. type port struct { Container int Host int Protocol string } // parsePortKey reads docker's "5432/tcp". func parsePortKey(key string) (int, string, error) { spec, proto, found := strings.Cut(key, "/") if !found { proto = "tcp" } n, err := strconv.Atoi(spec) if err != nil || n <= 0 || n > 65535 { return 0, "", fmt.Errorf("invalid port %q", key) } return n, proto, nil } // freePort asks the kernel for an unused port. There is a window // between this and the service binding it; werkdock accepts it, as // every port picker without a network namespace must. func freePort() (int, error) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, err } defer ln.Close() return ln.Addr().(*net.TCPAddr).Port, nil } // publishPorts decides where a container's ports really listen. // For a port the image can be moved (the quirk table), a requested // host port is honoured and an empty one is chosen free; the service // is then told through its environment variable. For every other port // the answer is the identity: the sandbox shares the host's network, // so the port the service binds is the port the client reaches. func publishPorts(exposed map[string]struct{}, bindings map[string][]portBinding, q quirk) ([]port, map[string]string, error) { keys := map[string]bool{} for k := range exposed { keys[k] = true } for k := range bindings { keys[k] = true } env := map[string]string{} var ports []port for key := range keys { container, proto, err := parsePortKey(key) if err != nil { return nil, nil, err } requested := 0 if list := bindings[key]; len(list) > 0 && list[0].HostPort != "" { if requested, err = strconv.Atoi(list[0].HostPort); err != nil { return nil, nil, fmt.Errorf("invalid host port %q for %s", list[0].HostPort, key) } } name, movable := q.PortEnv[container] switch { case movable && requested != 0: ports = append(ports, port{Container: container, Host: requested, Protocol: proto}) env[name] = strconv.Itoa(requested) case movable: free, err := freePort() if err != nil { return nil, nil, err } ports = append(ports, port{Container: container, Host: free, Protocol: proto}) env[name] = strconv.Itoa(free) case requested != 0 && requested != container: return nil, nil, fmt.Errorf( "cannot publish %s on host port %d: werkdock shares the host's network and cannot map ports; "+ "the service would have to bind %d itself, and werkdock does not know how to tell this image to", key, requested, requested) default: ports = append(ports, port{Container: container, Host: container, Protocol: proto}) } } sortPorts(ports) return ports, env, nil } func sortPorts(ports []port) { for i := 1; i < len(ports); i++ { for j := i; j > 0 && ports[j].Container < ports[j-1].Container; j-- { ports[j], ports[j-1] = ports[j-1], ports[j] } } }