// 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 }