werkator build succeeded after 00:03
Three of its four entries were wrong. redis, mariadb and mysql were all listed as moving their port through an environment variable — REDIS_PORT, MARIADB_TCP_PORT, MYSQL_TCP_PORT. None of the three images reads one. Werkdock picked a free port, told nobody, and reported it: a client got a number nothing was listening on, which is the single failure the whole quirk mechanism exists to prevent. It was found the only way it could be — by starting the images and connecting to the port werkdock named. redis-server takes --port; the two database entrypoints read the port out of the server's own configuration, so the flag has to reach the command line there too. A quirk can now move a port by a flag, and does so only while the container still runs the image's own program: appending an argument to a command the client chose would be a guess, and guessing is what this commit is about. Two more things surfaced on the way. Redis could not start at all — its entrypoint chowns its data directory as root, which an unprivileged user namespace cannot do, so it needs the same treatment postgres has. And an image's /run did not show through: /run is the instance's own directory so that sockets survive a stop, which left mariadb without /run/mysqld and dying on "Bind on unix socket: No such file or directory". The instance's /run is now seeded from the image's. postgres was right all along and is untouched; it really does read PGPORT. Squashed from branch quirk-ports and pushed to main directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
7.7 KiB
Go
228 lines
7.7 KiB
Go
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]
|
|
}
|
|
}
|
|
}
|