package api import ( "fmt" "net" "path" "sort" "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 // PortArg maps a container port to a command-line flag that does // the same, for a server that reads no environment variable for it. // It applies only when the command is still the image's own, so // werkdock never appends a flag to a command somebody else chose. PortArg map[int]portArg } // portArg is "append this flag and the port, but only to this program". // A Flag ending in "=" is joined to the number (--port=3306); otherwise // the two are separate words (--port 6379), which is what redis-server // wants. type portArg struct { Program string Flag string } // words renders the flag for a chosen port. func (a portArg) words(port int) []string { if strings.HasSuffix(a.Flag, "=") { return []string{a.Flag + strconv.Itoa(port)} } return []string{a.Flag, strconv.Itoa(port)} } var quirks = map[string]quirk{ "postgres": {User: "postgres", PortEnv: map[int]string{5432: "PGPORT"}}, // MariaDB's entrypoint reads the port out of the server's own // configuration (mysql_get_config 'port' "$@"), not out of the // environment: with MARIADB_TCP_PORT set the server stayed on 3306. // The flag reaches both the entrypoint's reading of it and the // server itself, because the entrypoint passes "$@" on. "mariadb": {User: "mysql", PortArg: map[int]portArg{3306: {Program: "mariadbd", Flag: "--port="}}}, // MySQL is MariaDB's relative and behaves the same way. 33060 is the // X protocol, which the image also opens: it moves only when a // client asks for it, so two instances at once share it and the // second one's X plugin fails while its SQL port works. "mysql": {User: "mysql", PortArg: map[int]portArg{ 3306: {Program: "mysqld", Flag: "--port="}, 33060: {Program: "mysqld", Flag: "--mysqlx-port="}, }}, // Redis reads no environment variable for its port — measured, not // assumed: with REDIS_PORT set the server stayed on 6379 and // werkdock reported a port nothing listened on. redis-server takes // --port instead. The user is needed for the same reason postgres // needs one: the entrypoint chowns its data directory as root, which // an unprivileged user namespace cannot do. "redis": {User: "redis", PortArg: map[int]portArg{6379: {Program: "redis-server", Flag: "--port"}}}, } // moves reports how werkdock would move this port, if it can: by // environment variable, by command-line flag, or not at all. A flag // counts only when the command is the image's own program, because // appending to a command the client chose would be a guess. func (q quirk) moves(container int, command []string) (env string, arg *portArg) { if name, ok := q.PortEnv[container]; ok { return name, nil } a, ok := q.PortArg[container] if !ok || len(command) == 0 { return "", nil } for _, word := range command { if path.Base(word) == a.Program { return "", &a } } return "", nil } // 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, command []string) ([]port, map[string]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 args []string var ports []port for _, key := range sortedPortKeys(keys) { container, proto, err := parsePortKey(key) if err != nil { return nil, 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, nil, fmt.Errorf("invalid host port %q for %s", list[0].HostPort, key) } } name, arg := q.moves(container, command) movable := name != "" || arg != nil host := requested switch { case movable && requested == 0: if host, err = freePort(); err != nil { return nil, nil, nil, err } case !movable && requested != 0 && requested != container: return nil, 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) case !movable: host = container } ports = append(ports, port{Container: container, Host: host, Protocol: proto}) switch { case name != "": env[name] = strconv.Itoa(host) case arg != nil: args = append(args, arg.words(host)...) } } sortPorts(ports) return ports, env, args, nil } func sortSlice(keys []string) []string { sort.Strings(keys); return keys } // sortedPortKeys makes the chosen ports reproducible when more than one // is published: map iteration order must not decide which gets which. func sortedPortKeys(keys map[string]bool) []string { out := make([]string, 0, len(keys)) for k := range keys { out = append(out, k) } return sortSlice(out) } 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] } } }