werkator build succeeded after 00:12
0.9.0 listed mosquitto as the image werkdock cannot move, and that was true of everything mosquitto offers: -p is ignored once a config file declares a listener (the manual says so, and it was measured), no environment variable names the port, and the config file expands none. What mosquitto does read is the config file — and a config file is the one thing werkdock can replace that a flag or a variable cannot reach. A quirk can now name a PortFile: which file the image ships carries the port and how the line reads. Werkdock copies that file into the instance directory with the line rewritten to the chosen port and binds the copy read-only over the image's original, which is never touched. Both of mosquitto's listeners live in the same file, so the dashboard's HTTP API on 9883 moves along with MQTT on 1883. Measured with 2.1.2 through the API socket: two instances at once, each on its own pair of ports, each accepting an anonymous client and answering on its dashboard, 1883 left free. The rewritten copy applies only while the command still names the file and the client has not bound a file of their own over that path; either way the copy would not be what the server reads, and the port is then reported unmoved. An image whose file no longer has the expected line is refused at create with the line named, not started on a port werkdock only claimed. Squashed from branch mosquitto-port-file and pushed to main directly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
402 lines
15 KiB
Go
402 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"werkdock/internal/engine"
|
|
"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
|
|
// PortFile maps a container port to the config file the image
|
|
// ships that names it, for a server that reads its port from
|
|
// nowhere else. Werkdock copies that file with the line rewritten
|
|
// and binds the copy over the original; the image's own file is
|
|
// never touched. It applies only while the command still names
|
|
// the file (mosquitto -c /mosquitto/config/mosquitto.conf) and
|
|
// the client has not bound something of its own over it.
|
|
PortFile map[int]portFile
|
|
// 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)}
|
|
}
|
|
|
|
// portFile is "this line in this file names the port". Line is a
|
|
// format with one %d; the exact line for the image's own port is
|
|
// replaced by the same line for the chosen one, nothing else in the
|
|
// file changes.
|
|
type portFile struct {
|
|
Path string
|
|
Line string
|
|
}
|
|
|
|
// lines renders the line as the image ships it and as it is to be.
|
|
func (f portFile) lines(from, to int) (string, string) {
|
|
return fmt.Sprintf(f.Line, from), fmt.Sprintf(f.Line, to)
|
|
}
|
|
|
|
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. Its
|
|
// port is the hard case: the config the image ships declares its
|
|
// listeners explicitly, an explicit listener turns off the default
|
|
// one that -p configures (the manual says so, and measured: with
|
|
// -p it went on listening on 1883), it reads no environment
|
|
// variable, and the config file knows no variables either. What
|
|
// is left is the config file itself: the shipped one, copied with
|
|
// "listener 1883" rewritten, bound over the original. The second
|
|
// listener, the dashboard's HTTP API on 9883, moves the same way.
|
|
// Measured with 2.1.2: both listeners came up on the rewritten
|
|
// ports and an anonymous client was accepted, as the shipped
|
|
// config allows.
|
|
"eclipse-mosquitto": {User: "mosquitto", PortFile: map[int]portFile{
|
|
1883: {Path: "/mosquitto/config/mosquitto.conf", Line: "listener %d"},
|
|
9883: {Path: "/mosquitto/config/mosquitto.conf", Line: "listener %d"},
|
|
}},
|
|
// 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, by rewriting a config
|
|
// file, 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; a file counts only when the command still
|
|
// names it and the client has not bound one of their own over it
|
|
// (supplied lists the destinations of the request's binds), because
|
|
// then the rewritten copy would not be what the server reads.
|
|
func (q quirk) moves(container int, command, supplied []string) (env string, arg *portArg, file *portFile) {
|
|
if name, ok := q.PortEnv[container]; ok {
|
|
return name, nil, nil
|
|
}
|
|
if a, ok := q.PortArg[container]; ok {
|
|
for _, word := range command {
|
|
if path.Base(word) == a.Program {
|
|
return "", &a, nil
|
|
}
|
|
}
|
|
}
|
|
if f, ok := q.PortFile[container]; ok {
|
|
if slices.Contains(command, f.Path) && !slices.Contains(supplied, f.Path) {
|
|
return "", nil, &f
|
|
}
|
|
}
|
|
return "", nil, 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)
|
|
}
|
|
|
|
// moved is everything the sandbox has to be given so that the service
|
|
// binds the ports werkdock reported: environment, command-line words,
|
|
// and config files to rewrite.
|
|
type moved struct {
|
|
Env map[string]string
|
|
Args []string
|
|
Files []fileEdit
|
|
}
|
|
|
|
// fileEdit is one port's line in one file, from the image's number to
|
|
// the chosen one.
|
|
type fileEdit struct {
|
|
File portFile
|
|
From, To int
|
|
}
|
|
|
|
// 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, a flag, or its config
|
|
// file. 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. command is what the sandbox will run and
|
|
// supplied the destinations of the request's own binds; both decide
|
|
// whether a flag or a file can be applied at all.
|
|
func publishPorts(exposed map[string]struct{}, bindings map[string][]portBinding, q quirk, command, supplied []string) ([]port, moved, error) {
|
|
keys := map[string]bool{}
|
|
for k := range exposed {
|
|
keys[k] = true
|
|
}
|
|
for k := range bindings {
|
|
keys[k] = true
|
|
}
|
|
m := moved{Env: map[string]string{}}
|
|
var ports []port
|
|
for _, key := range sortedPortKeys(keys) {
|
|
container, proto, err := parsePortKey(key)
|
|
if err != nil {
|
|
return nil, moved{}, 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, moved{}, fmt.Errorf("invalid host port %q for %s", list[0].HostPort, key)
|
|
}
|
|
}
|
|
name, arg, file := q.moves(container, command, supplied)
|
|
movable := name != "" || arg != nil || file != nil
|
|
host := requested
|
|
switch {
|
|
case movable && requested == 0:
|
|
if host, err = freePort(q.MaxHostPort); err != nil {
|
|
return nil, moved{}, err
|
|
}
|
|
case movable && q.MaxHostPort != 0 && requested > q.MaxHostPort:
|
|
return nil, moved{}, 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, moved{}, 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 != "":
|
|
m.Env[name] = strconv.Itoa(host)
|
|
case arg != nil:
|
|
m.Args = append(m.Args, arg.words(host)...)
|
|
case file != nil:
|
|
m.Files = append(m.Files, fileEdit{File: *file, From: container, To: host})
|
|
}
|
|
}
|
|
sortPorts(ports)
|
|
return ports, m, nil
|
|
}
|
|
|
|
// A renderedFile is a config file with its port lines rewritten, ready
|
|
// to be written below the instance directory and bound over the
|
|
// image's copy.
|
|
type renderedFile struct {
|
|
Dest string
|
|
Content []byte
|
|
}
|
|
|
|
// renderPortFiles reads each file the edits name out of the image and
|
|
// rewrites the lines. Every line must be there: a quirk is measured
|
|
// knowledge about one image, and an image whose file has changed is
|
|
// refused with the reason rather than started on a port werkdock only
|
|
// claimed. Files come back in path order, one per path however many
|
|
// ports it carries.
|
|
func renderPortFiles(rootfs string, edits []fileEdit) ([]renderedFile, error) {
|
|
byPath := map[string][]fileEdit{}
|
|
for _, e := range edits {
|
|
byPath[e.File.Path] = append(byPath[e.File.Path], e)
|
|
}
|
|
paths := make([]string, 0, len(byPath))
|
|
for p := range byPath {
|
|
paths = append(paths, p)
|
|
}
|
|
var out []renderedFile
|
|
for _, p := range sortSlice(paths) {
|
|
raw, err := os.ReadFile(filepath.Join(rootfs, filepath.FromSlash(p)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the port of this image lives in %s, which the image does not have: %w", p, err)
|
|
}
|
|
lines := strings.Split(string(raw), "\n")
|
|
for _, e := range byPath[p] {
|
|
from, to := e.File.lines(e.From, e.To)
|
|
found := false
|
|
for i, line := range lines {
|
|
if strings.TrimSpace(line) == from {
|
|
lines[i], found = to, true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("cannot move port %d: werkdock expected %q in the image's %s and this image's copy has no such line", e.From, from, p)
|
|
}
|
|
}
|
|
out = append(out, renderedFile{Dest: p, Content: []byte(strings.Join(lines, "\n"))})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// placePortFiles writes the rendered files below the instance
|
|
// directory and returns the read-only binds that put each over the
|
|
// image's original.
|
|
func placePortFiles(dir string, files []renderedFile) ([]engine.Mount, error) {
|
|
var mounts []engine.Mount
|
|
for _, f := range files {
|
|
name := strings.ReplaceAll(strings.TrimPrefix(f.Dest, "/"), "/", "-")
|
|
source := filepath.Join(dir, "quirk", name)
|
|
if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.WriteFile(source, f.Content, 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
mounts = append(mounts, engine.Mount{Mode: engine.MountRoBind, Source: source, Dest: f.Dest})
|
|
}
|
|
return mounts, 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]
|
|
}
|
|
}
|
|
}
|