292 lines
9.0 KiB
Go
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
|
|
}
|