Files
werkdock/internal/api/quirks.go
miandmhoennig f9c1ae835d Api socket (#5)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #5
2026-09-10 22:09:58 +02:00

150 lines
4.8 KiB
Go

package api
import (
"fmt"
"net"
"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
}
var quirks = map[string]quirk{
"postgres": {User: "postgres", PortEnv: map[int]string{5432: "PGPORT"}},
"mariadb": {User: "mysql", PortEnv: map[int]string{3306: "MARIADB_TCP_PORT"}},
"mysql": {User: "mysql", PortEnv: map[int]string{3306: "MYSQL_TCP_PORT"}},
"redis": {PortEnv: map[int]string{6379: "REDIS_PORT"}},
}
// 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) ([]port, map[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 ports []port
for key := range keys {
container, proto, err := parsePortKey(key)
if err != nil {
return 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, fmt.Errorf("invalid host port %q for %s", list[0].HostPort, key)
}
}
name, movable := q.PortEnv[container]
switch {
case movable && requested != 0:
ports = append(ports, port{Container: container, Host: requested, Protocol: proto})
env[name] = strconv.Itoa(requested)
case movable:
free, err := freePort()
if err != nil {
return nil, nil, err
}
ports = append(ports, port{Container: container, Host: free, Protocol: proto})
env[name] = strconv.Itoa(free)
case requested != 0 && requested != container:
return 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)
default:
ports = append(ports, port{Container: container, Host: container, Protocol: proto})
}
}
sortPorts(ports)
return ports, env, nil
}
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]
}
}
}