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

361 lines
11 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)
ports, portEnv, err := publishPorts(req.ExposedPorts, req.HostConfig.PortBindings, q)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
spec, warnings, err := buildSpec(req, rootfs, meta.Config, q, portEnv)
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
}
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) (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
}
entrypoint, cmd := config.Entrypoint, config.Cmd
if len(req.Entrypoint) > 0 {
entrypoint, cmd = req.Entrypoint, nil
}
if len(req.Cmd) > 0 {
cmd = req.Cmd
}
spec.Command = append(append([]string{}, entrypoint...), cmd...)
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")
}
// 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
}
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)
}