Files
werkdock/internal/api/quirks.go
mhoennigandClaude Opus 5 ce20df9f76
werkator build succeeded after 00:03
The quirk table, measured instead of assumed (#8)
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>
2026-09-11 08:10:50 +02:00

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]
}
}
}