werkator build succeeded after 00:03
PUT /containers/{id}/archive was the last endpoint Testcontainers asks
for that werkdock answered with a 501. It is what withCopyFileToContainer
sends, and with it an image's own init mechanism works: a script copied
to /docker-entrypoint-initdb.d is run by the postgres entrypoint.
Docker unpacks the tar into the container's writable layer. Werkdock has
none, and its rootfs is the image's own directory in the store, shared by
every instance and bound read-only — unpacking there would change the
image for everyone. So the tar goes into the instance directory and is
bound into the sandbox, one of two ways, and the image decides which:
whole where the image has nothing at the destination, file by file where
it has something, so its own files stay visible beside the copied ones.
That is docker's merge semantics, reached by another road.
The real client corrected the design once: Testcontainers sends path=/
and carries the whole path in the tar entry names. An afternoon's
assumption that / should be refused would have refused every copy there
is. The measured call is now written down in docs/testcontainers-api.md.
Refused rather than ignored: a copy into a running instance (mounts are
fixed at start), a directory onto one the image fills (the bind would
hide it), symlinks and device nodes, entries that climb out of the
destination, and archives above 64 entries.
Squashed from branch container-archive and pushed to main directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
293 lines
9.1 KiB
Go
293 lines
9.1 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("PUT /containers/{id}/archive", s.containerArchivePut)
|
|
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
|
|
}
|