werkator build succeeded after 00:52
Three servers, three lessons the quirk table could not express before. NATS was the easy one: nats-server takes --port, and the flag wins over the port in the config the image ships. RabbitMQ reads RABBITMQ_NODE_PORT, but the port it is given is not the only one it derives from it — the Erlang distribution port is that plus 20000. A node port above 45535 makes it refuse to boot, and the kernel's ephemeral range, which freePort draws from, starts at 32768: more than half of werkdock's choices would have been wrong. A quirk can now cap what werkdock picks, and werkdock looks for a free port itself below the cap. A requested port above it is refused with the reason rather than handed to a server that dies in an Erlang stack trace. Mosquitto cannot be moved at all. Its port comes from the config the image ships, whose explicit listener switches off the default one that -p sets; measured, the flag is simply ignored. Its entry therefore names a user and no port knob — and it is still worth having, because without the user mosquitto does not start (it drops privileges itself and cannot setgroups in a user namespace), and with the entry werkdock reports 1883 unmoved instead of a port nothing answers on. All five tests for these are in werkprobe, including queue and topic delivery through RabbitMQ and a topic through MQTT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
10 KiB
Go
274 lines
10 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
|
|
// MaxHostPort caps what werkdock may choose for this image, for a
|
|
// server that derives a second port from the one it is given.
|
|
// RabbitMQ's Erlang distribution port is the node port plus 20000
|
|
// and must still be a port, so its node port cannot exceed 45535 —
|
|
// and the kernel's ephemeral range, where freePort draws from,
|
|
// starts above that more often than not. Zero means no cap.
|
|
MaxHostPort int
|
|
}
|
|
|
|
// 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="},
|
|
}},
|
|
// NATS takes --port, and its config file does not get in the way:
|
|
// a flag on the command line wins over the port in the config the
|
|
// image ships. Measured.
|
|
"nats": {PortArg: map[int]portArg{4222: {Program: "nats-server", Flag: "--port"}}},
|
|
// RabbitMQ does read its environment variable, but the port it is
|
|
// given is not the only one it derives from it: the Erlang
|
|
// distribution port is that plus 20000. A node port above 45535
|
|
// therefore fails to boot with "Invalid Erlang distribution TCP
|
|
// port", which is what MaxHostPort is for. It also chowns its data
|
|
// directory as root, like postgres and redis.
|
|
"rabbitmq": {User: "rabbitmq", PortEnv: map[int]string{5672: "RABBITMQ_NODE_PORT"}, MaxHostPort: 45535},
|
|
// Mosquitto needs its own user for the same reason — it drops
|
|
// privileges itself and cannot setgroups in a user namespace — but
|
|
// its port cannot be moved at all: the config the image ships
|
|
// declares its listeners explicitly, and an explicit listener turns
|
|
// off the default one that -p configures. So it keeps 1883, which
|
|
// is correct as long as one instance runs at a time. Measured: with
|
|
// -p it went on listening on 1883.
|
|
"eclipse-mosquitto": {User: "mosquitto"},
|
|
// 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.
|
|
//
|
|
// A max caps the answer. The kernel's own choice comes from the
|
|
// ephemeral range, which on Linux starts at 32768 and so lies above
|
|
// some images' limit more often than not; below a cap werkdock has to
|
|
// look for itself, upwards from the end of the registered range.
|
|
func freePort(max int) (int, error) {
|
|
if max <= 0 {
|
|
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
|
|
}
|
|
for port := 20000; port <= max; port++ {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ln.Close()
|
|
return port, nil
|
|
}
|
|
return 0, fmt.Errorf("no free port below %d", max)
|
|
}
|
|
|
|
// 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(q.MaxHostPort); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
case movable && q.MaxHostPort != 0 && requested > q.MaxHostPort:
|
|
return nil, nil, nil, fmt.Errorf(
|
|
"cannot publish %s on host port %d: this image derives a second port from the one it is "+
|
|
"given and cannot be sent above %d", key, requested, q.MaxHostPort)
|
|
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]
|
|
}
|
|
}
|
|
}
|