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

292 lines
9.0 KiB
Go

// Package api serves the subset of the Docker Engine API that
// Testcontainers actually uses, measured rather than guessed
// (docs/testcontainers-api.md, RFC 0005 milestone 3). It is a frontend
// over package instance and package store, exactly as the CLI verbs
// are; it owns no lifecycle logic of its own.
//
// Everything outside the subset answers 501 with the reason, never 404:
// a client that needs more must see why, and the answer is the next
// milestone's to-do list.
package api
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"werkdock/internal/instance"
"werkdock/internal/store"
)
// APIVersion is what werkdock claims to speak. docker-java asks for
// /v1.32/ and accepts anything at least as new.
const APIVersion = "1.41"
// EngineVersion is the Docker Engine release whose API werkdock
// answers as. It is not werkdock's version and cannot be: clients
// compare this field against a minimum (Testcontainers refuses
// anything below 1.6.0, and werkdock's 0.x would fail forever).
// Werkdock's own version is reported beside it, in the Components list
// and in /info, where it is read by people rather than by code.
const EngineVersion = "25.0.0"
// Server answers Engine API requests against one store.
type Server struct {
Store store.Store
Manager instance.Manager
// Executable is the werkdock binary a detached supervisor is
// started from; empty means os.Executable.
Executable string
// Version is werkdock's version, reported by /version and /info.
Version string
// Log receives one line per request; nil discards.
Log *log.Logger
mu sync.Mutex
last time.Time
}
// LastRequest is when the server last answered anything, for the idle
// timeout of `werkdock system service --time`.
func (s *Server) LastRequest() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.last
}
func (s *Server) touch() {
s.mu.Lock()
s.last = time.Now()
s.mu.Unlock()
}
func (s *Server) logf(format string, a ...interface{}) {
if s.Log != nil {
s.Log.Printf(format, a...)
}
}
// versionPrefix is docker's /v1.41 path prefix, which every client
// sends and no route needs.
var versionPrefix = regexp.MustCompile(`^/v[0-9]+(\.[0-9]+)*`)
// Handler is the routed, logged handler for the whole API.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /_ping", s.ping)
mux.HandleFunc("HEAD /_ping", s.ping)
mux.HandleFunc("GET /version", s.version)
mux.HandleFunc("GET /info", s.info)
mux.HandleFunc("GET /images/json", s.imagesList)
mux.HandleFunc("POST /images/create", s.imagesCreate)
mux.HandleFunc("POST /images/prune", s.pruneNothing)
mux.HandleFunc("GET /images/", s.imageInspect)
mux.HandleFunc("GET /containers/json", s.containersList)
mux.HandleFunc("POST /containers/create", s.containerCreate)
mux.HandleFunc("GET /containers/{id}/json", s.containerInspect)
mux.HandleFunc("POST /containers/{id}/start", s.containerStart)
mux.HandleFunc("POST /containers/{id}/stop", s.containerStop)
mux.HandleFunc("POST /containers/{id}/kill", s.containerKill)
mux.HandleFunc("POST /containers/{id}/wait", s.containerWait)
mux.HandleFunc("GET /containers/{id}/logs", s.containerLogs)
mux.HandleFunc("DELETE /containers/{id}", s.containerRemove)
mux.HandleFunc("POST /containers/{id}/exec", s.execCreate)
mux.HandleFunc("POST /containers/prune", s.pruneNothing)
mux.HandleFunc("POST /exec/{id}/start", s.execStart)
mux.HandleFunc("GET /exec/{id}/json", s.execInspect)
mux.HandleFunc("POST /networks/prune", s.pruneNothing)
mux.HandleFunc("POST /volumes/prune", s.pruneNothing)
mux.HandleFunc("/", s.notImplemented)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.touch()
r.URL.Path = versionPrefix.ReplaceAllString(r.URL.Path, "")
if r.URL.Path == "" {
r.URL.Path = "/"
}
started := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
w.Header().Set("Api-Version", APIVersion)
w.Header().Set("Server", "werkdock/"+s.Version)
mux.ServeHTTP(rec, r)
s.logf("%s %s%s -> %d (%s)", r.Method, r.URL.Path, query(r), rec.status, time.Since(started).Round(time.Millisecond))
})
}
func query(r *http.Request) string {
if r.URL.RawQuery == "" {
return ""
}
return "?" + r.URL.RawQuery
}
type statusRecorder struct {
http.ResponseWriter
status int
written bool
}
func (w *statusRecorder) WriteHeader(code int) {
if !w.written {
w.status, w.written = code, true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(p []byte) (int, error) {
w.written = true
return w.ResponseWriter.Write(p)
}
func (w *statusRecorder) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
return
}
}
// errorJSON is docker's error body: {"message": "..."}.
func writeError(w http.ResponseWriter, status int, format string, a ...interface{}) {
writeJSON(w, status, map[string]string{"message": fmt.Sprintf(format, a...)})
}
func (s *Server) notImplemented(w http.ResponseWriter, r *http.Request) {
s.logf("NOT IMPLEMENTED: %s %s%s", r.Method, r.URL.Path, query(r))
writeError(w, http.StatusNotImplemented,
"werkdock's API serves the subset Testcontainers uses (see docs/testcontainers-api.md); %s %s is not part of it",
r.Method, r.URL.Path)
}
func (s *Server) ping(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Docker-Experimental", "false")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
io.WriteString(w, "OK")
}
func (s *Server) version(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"Platform": map[string]string{"Name": "werkdock"},
"Version": EngineVersion,
"ApiVersion": APIVersion,
"MinAPIVersion": "1.24",
"GitCommit": "",
"GoVersion": runtime.Version(),
"Os": runtime.GOOS,
"Arch": runtime.GOARCH,
"KernelVersion": kernelVersion(),
"BuildTime": "",
"Components": []map[string]interface{}{{
"Name": "Werkdock Engine",
"Version": s.Version,
"Details": map[string]string{
"ApiVersion": APIVersion,
"Arch": runtime.GOARCH,
"GoVersion": runtime.Version(),
"KernelVersion": kernelVersion(),
"MinAPIVersion": "1.24",
"Os": runtime.GOOS,
},
}},
})
}
// info reports what a client may reasonably read, and says plainly
// what werkdock is: rootless, daemonless, filesystem isolation only.
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
instances, err := s.Manager.List()
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
running := 0
for _, i := range instances {
if i.Running() {
running++
}
}
images, _ := s.Store.List()
host, _ := os.Hostname()
writeJSON(w, http.StatusOK, map[string]interface{}{
"ID": "werkdock",
"Name": host,
"ServerVersion": EngineVersion,
"OperatingSystem": "werkdock " + s.Version + " (bwrap, filesystem isolation only)",
"OSType": runtime.GOOS,
"Architecture": runtime.GOARCH,
"KernelVersion": kernelVersion(),
"Driver": "flat",
"DockerRootDir": s.Store.Root,
"Containers": len(instances),
"ContainersRunning": running,
"ContainersPaused": 0,
"ContainersStopped": len(instances) - running,
"Images": len(images),
"MemTotal": 0,
"NCPU": runtime.NumCPU(),
"SecurityOptions": []string{"name=rootless", "name=userns"},
"Swarm": map[string]interface{}{"LocalNodeState": "inactive", "ControlAvailable": false},
"Runtimes": map[string]interface{}{"bwrap": map[string]string{"path": "bwrap"}},
"DefaultRuntime": "bwrap",
"CgroupDriver": "none",
"LiveRestoreEnabled": false,
"Warnings": []string{"werkdock shares the host's network: a published port is the port the service binds"},
})
}
func kernelVersion() string {
data, err := os.ReadFile("/proc/sys/kernel/osrelease")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// pruneNothing answers docker's prune calls: werkdock has no networks,
// no named volumes, and prunes no images behind a caller's back, so
// there is never anything to report.
func (s *Server) pruneNothing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ContainersDeleted": []string{},
"ImagesDeleted": []string{},
"NetworksDeleted": []string{},
"VolumesDeleted": []string{},
"SpaceReclaimed": 0,
})
}
func boolParam(r *http.Request, name string) bool {
v := r.URL.Query().Get(name)
return v == "1" || strings.EqualFold(v, "true")
}
func intParam(r *http.Request, name string, def int) int {
if v := r.URL.Query().Get(name); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}