150 lines
4.8 KiB
Go
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]
|
|
}
|
|
}
|
|
}
|