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>
410 lines
13 KiB
Go
410 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"werkdock/internal/engine"
|
|
"werkdock/internal/instance"
|
|
"werkdock/internal/store"
|
|
)
|
|
|
|
// portBinding is docker's {"HostIp":"","HostPort":"34259"}.
|
|
type portBinding struct {
|
|
HostIp string `json:"HostIp"`
|
|
HostPort string `json:"HostPort"`
|
|
}
|
|
|
|
// createRequest is the part of docker's container create body werkdock
|
|
// can honour. Everything else the client sends (and it sends every
|
|
// field, mostly null) is ignored unless it promises isolation werkdock
|
|
// does not have, which is refused below.
|
|
type createRequest struct {
|
|
Hostname string `json:"Hostname"`
|
|
User string `json:"User"`
|
|
Env []string `json:"Env"`
|
|
Cmd []string `json:"Cmd"`
|
|
Entrypoint []string `json:"Entrypoint"`
|
|
Image string `json:"Image"`
|
|
WorkingDir string `json:"WorkingDir"`
|
|
Labels map[string]string `json:"Labels"`
|
|
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
|
|
Tty bool `json:"Tty"`
|
|
HostConfig struct {
|
|
Binds []string `json:"Binds"`
|
|
PortBindings map[string][]portBinding `json:"PortBindings"`
|
|
AutoRemove bool `json:"AutoRemove"`
|
|
NetworkMode string `json:"NetworkMode"`
|
|
Privileged bool `json:"Privileged"`
|
|
Tmpfs map[string]string `json:"Tmpfs"`
|
|
} `json:"HostConfig"`
|
|
}
|
|
|
|
func (s *Server) containerCreate(w http.ResponseWriter, r *http.Request) {
|
|
var req createRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid create body: %v", err)
|
|
return
|
|
}
|
|
if req.Image == "" {
|
|
writeError(w, http.StatusBadRequest, "create needs an Image")
|
|
return
|
|
}
|
|
if req.HostConfig.Privileged {
|
|
writeError(w, http.StatusBadRequest, "privileged containers are not available: werkdock isolates the filesystem only and has no privileges to grant")
|
|
return
|
|
}
|
|
switch req.HostConfig.NetworkMode {
|
|
case "", "host", "default", "bridge":
|
|
// the sandbox has the host's network by contract; the three
|
|
// docker names a client sends for "just connect me" are taken
|
|
// as that (RFC 0002).
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "network mode %q is not available in an unprivileged sandbox; werkdock shares the host's network", req.HostConfig.NetworkMode)
|
|
return
|
|
}
|
|
ref, meta, err := s.resolveImage(req.Image)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "no such image: %s", req.Image)
|
|
return
|
|
}
|
|
rootfs, err := s.Store.RootFS(ref)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
q := quirkFor(ref)
|
|
// The command has to be known before the ports are: an image that
|
|
// is moved by a command-line flag can only be moved while it still
|
|
// runs its own program.
|
|
command := effectiveCommand(req, meta.Config)
|
|
ports, moved, err := publishPorts(req.ExposedPorts, req.HostConfig.PortBindings, q, command, bindDests(req))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
// A port that lives in a config file is moved by a rewritten copy
|
|
// of that file. Rendered now, so that an image whose file has
|
|
// changed is refused before anything is created; written once the
|
|
// instance directory exists.
|
|
portFiles, err := renderPortFiles(rootfs, moved.Files)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
spec, warnings, err := buildSpec(req, rootfs, meta.Config, q, moved.Env, moved.Args)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
for _, warning := range warnings {
|
|
s.logf("create %s: %s", ref, warning)
|
|
}
|
|
if err := engine.EnsureMountpoints(spec); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
var mappings []instance.PortMapping
|
|
for _, p := range ports {
|
|
mappings = append(mappings, instance.PortMapping{ContainerPort: p.Container, HostPort: p.Host, Protocol: p.Protocol})
|
|
}
|
|
inst, err := s.Manager.Create(instance.Config{
|
|
Name: strings.TrimPrefix(r.URL.Query().Get("name"), "/"),
|
|
Image: ref,
|
|
Spec: spec,
|
|
Volumes: anonymousVolumes(meta.Config.Volumes, spec.Mounts),
|
|
AutoRemove: req.HostConfig.AutoRemove,
|
|
Labels: req.Labels,
|
|
Ports: mappings,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
if len(portFiles) > 0 {
|
|
mounts, err := placePortFiles(inst.Dir, portFiles)
|
|
if err == nil {
|
|
inst.Config.Spec.Mounts = append(inst.Config.Spec.Mounts, mounts...)
|
|
err = inst.SaveConfig()
|
|
}
|
|
if err != nil {
|
|
s.Manager.Remove(inst, true)
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
}
|
|
s.logf("created %s (%s) as uid %d, ports %v", inst.ShortID(), inst.Config.Name, spec.UID, mappings)
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{"Id": inst.Config.ID, "Warnings": warnings})
|
|
}
|
|
|
|
// buildSpec merges the request with the image configuration the docker
|
|
// way, and applies the two werkdock corrections: the port environment
|
|
// the service must bind, and the user an image needs that expects to
|
|
// drop privileges itself (see quirk).
|
|
func buildSpec(req createRequest, rootfs string, config store.ImageConfig, q quirk, portEnv map[string]string, portArgs []string) (engine.RunSpec, []string, error) {
|
|
warnings := []string{}
|
|
spec := engine.RunSpec{RootFS: rootfs, Workdir: req.WorkingDir}
|
|
for _, bind := range req.HostConfig.Binds {
|
|
mount, err := parseBind(bind)
|
|
if err != nil {
|
|
return engine.RunSpec{}, nil, err
|
|
}
|
|
spec.Mounts = append(spec.Mounts, mount)
|
|
}
|
|
for dest := range req.HostConfig.Tmpfs {
|
|
spec.Mounts = append(spec.Mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: dest})
|
|
}
|
|
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
|
|
spec.Mounts = append([]engine.Mount{{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"}}, spec.Mounts...)
|
|
}
|
|
for _, e := range config.Env {
|
|
key, value, _ := strings.Cut(e, "=")
|
|
spec.Env = append(spec.Env, engine.EnvVar{Key: key, Value: value})
|
|
}
|
|
for _, e := range req.Env {
|
|
key, value, _ := strings.Cut(e, "=")
|
|
spec.Env = append(spec.Env, engine.EnvVar{Key: key, Value: value})
|
|
}
|
|
for _, key := range sortedKeys(portEnv) {
|
|
spec.Env = append(spec.Env, engine.EnvVar{Key: key, Value: portEnv[key]})
|
|
}
|
|
if spec.Workdir == "" {
|
|
spec.Workdir = config.WorkingDir
|
|
}
|
|
spec.Command = effectiveCommand(req, config)
|
|
if len(spec.Command) == 0 {
|
|
return engine.RunSpec{}, nil, errors.New("no command: the request has none and the image has no ENTRYPOINT or CMD")
|
|
}
|
|
// A port moved by a flag rather than an environment variable
|
|
// (redis-server --port): publishPorts already checked that the
|
|
// command is the image's own program.
|
|
spec.Command = append(spec.Command, portArgs...)
|
|
// The user comes from the request, else the image, else the quirk
|
|
// table. The first two are instructions and must be met; the third
|
|
// is werkdock's own guess about an image that drops privileges by
|
|
// itself, so a mismatch is a warning and the sandbox stays at uid 0.
|
|
user, guessed := req.User, false
|
|
if user == "" {
|
|
user = config.User
|
|
}
|
|
if user == "" {
|
|
user, guessed = q.User, true
|
|
}
|
|
if user != "" {
|
|
u, err := engine.LookupUser(rootfs, user)
|
|
switch {
|
|
case err == nil:
|
|
spec.UID, spec.GID, spec.Home = u.UID, u.GID, u.Home
|
|
case guessed:
|
|
warnings = append(warnings, fmt.Sprintf("this image usually runs as %q, which it does not define; running as uid 0", user))
|
|
default:
|
|
return engine.RunSpec{}, nil, fmt.Errorf("user %q: %w", user, err)
|
|
}
|
|
}
|
|
return spec, warnings, nil
|
|
}
|
|
|
|
// bindDests lists where the request's own binds land, so that a
|
|
// config file the client supplies is not overridden by werkdock's
|
|
// rewritten one.
|
|
func bindDests(req createRequest) []string {
|
|
var dests []string
|
|
for _, bind := range req.HostConfig.Binds {
|
|
if m, err := parseBind(bind); err == nil {
|
|
dests = append(dests, m.Dest)
|
|
}
|
|
}
|
|
return dests
|
|
}
|
|
|
|
// effectiveCommand merges request and image the docker way: an
|
|
// entrypoint from the request replaces the image's and drops its CMD;
|
|
// a command from the request replaces the image's CMD.
|
|
func effectiveCommand(req createRequest, config store.ImageConfig) []string {
|
|
entrypoint, cmd := config.Entrypoint, config.Cmd
|
|
if len(req.Entrypoint) > 0 {
|
|
entrypoint, cmd = req.Entrypoint, nil
|
|
}
|
|
if len(req.Cmd) > 0 {
|
|
cmd = req.Cmd
|
|
}
|
|
return append(append([]string{}, entrypoint...), cmd...)
|
|
}
|
|
|
|
func sortedKeys(m map[string]string) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func parseBind(bind string) (engine.Mount, error) {
|
|
parts := strings.Split(bind, ":")
|
|
if len(parts) < 2 {
|
|
return engine.Mount{}, fmt.Errorf("invalid bind %q: werkdock has no named volumes, a bind needs SRC:DEST", bind)
|
|
}
|
|
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
|
|
if !filepath.IsAbs(mount.Source) {
|
|
return engine.Mount{}, fmt.Errorf("invalid bind %q: werkdock has no named volumes, the source must be an absolute path", bind)
|
|
}
|
|
if len(parts) > 2 {
|
|
for _, opt := range strings.Split(parts[2], ",") {
|
|
if opt == "ro" || opt == "readonly" {
|
|
mount.Mode = engine.MountRoBind
|
|
}
|
|
}
|
|
}
|
|
return mount, nil
|
|
}
|
|
|
|
// anonymousVolumes is the image's VOLUME paths that no bind covers.
|
|
func anonymousVolumes(volumes map[string]struct{}, mounts []engine.Mount) []string {
|
|
var paths []string
|
|
for p := range volumes {
|
|
p = filepath.Clean(p)
|
|
covered := false
|
|
for _, m := range mounts {
|
|
dest := filepath.Clean(m.Dest)
|
|
if p == dest || strings.HasPrefix(p, strings.TrimSuffix(dest, "/")+"/") {
|
|
covered = true
|
|
break
|
|
}
|
|
}
|
|
if !covered {
|
|
paths = append(paths, p)
|
|
}
|
|
}
|
|
sort.Strings(paths)
|
|
return paths
|
|
}
|
|
|
|
// instanceOf resolves the {id} of a container route, answering 404 the
|
|
// way docker does when it is unknown.
|
|
func (s *Server) instanceOf(w http.ResponseWriter, r *http.Request) (*instance.Instance, bool) {
|
|
id := r.PathValue("id")
|
|
inst, err := s.Manager.Get(id)
|
|
if err != nil {
|
|
if errors.Is(err, instance.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "no such container: %s", id)
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
}
|
|
return nil, false
|
|
}
|
|
return inst, true
|
|
}
|
|
|
|
func (s *Server) containerStart(w http.ResponseWriter, r *http.Request) {
|
|
inst, ok := s.instanceOf(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if inst.Running() {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
executable := s.Executable
|
|
if executable == "" {
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
executable = self
|
|
}
|
|
if err := s.Manager.StartDetached(inst, executable); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) containerStop(w http.ResponseWriter, r *http.Request) {
|
|
inst, ok := s.instanceOf(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !inst.Running() {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
timeout := time.Duration(intParam(r, "t", 10)) * time.Second
|
|
if err := s.Manager.Stop(inst, timeout); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) containerKill(w http.ResponseWriter, r *http.Request) {
|
|
inst, ok := s.instanceOf(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
sig := syscall.SIGKILL
|
|
if name := r.URL.Query().Get("signal"); name != "" {
|
|
parsed, err := parseSignal(name)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "%v", err)
|
|
return
|
|
}
|
|
sig = parsed
|
|
}
|
|
if err := s.Manager.Kill(inst, sig); err != nil {
|
|
writeError(w, http.StatusConflict, "%v", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
var signalNames = map[string]syscall.Signal{
|
|
"HUP": syscall.SIGHUP, "INT": syscall.SIGINT, "QUIT": syscall.SIGQUIT, "KILL": syscall.SIGKILL,
|
|
"USR1": syscall.SIGUSR1, "USR2": syscall.SIGUSR2, "TERM": syscall.SIGTERM,
|
|
}
|
|
|
|
func parseSignal(s string) (syscall.Signal, error) {
|
|
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
|
return syscall.Signal(n), nil
|
|
}
|
|
if sig, ok := signalNames[strings.TrimPrefix(strings.ToUpper(s), "SIG")]; ok {
|
|
return sig, nil
|
|
}
|
|
return 0, fmt.Errorf("unknown signal %q", s)
|
|
}
|
|
|
|
func (s *Server) containerWait(w http.ResponseWriter, r *http.Request) {
|
|
inst, ok := s.instanceOf(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
code, err := s.Manager.Wait(inst, 0)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"StatusCode": code})
|
|
}
|
|
|
|
func (s *Server) containerRemove(w http.ResponseWriter, r *http.Request) {
|
|
inst, ok := s.instanceOf(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.Manager.Remove(inst, boolParam(r, "force")); err != nil {
|
|
writeError(w, http.StatusConflict, "%v", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|