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

239 lines
6.7 KiB
Go

package api
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"werkdock/internal/engine"
"werkdock/internal/instance"
)
// stdout and stderr as docker's stream multiplexing numbers them.
const (
streamStdout byte = 1
streamStderr byte = 2
)
// frameWriter wraps a stream in docker's frame format: an 8-byte
// header per write, the stream number first and the payload length in
// the last four bytes, big-endian. Every frame is flushed, so a client
// following a log sees output as it happens.
type frameWriter struct {
w io.Writer
stream byte
flush func()
}
func (f *frameWriter) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
var header [8]byte
header[0] = f.stream
binary.BigEndian.PutUint32(header[4:], uint32(len(p)))
if _, err := f.w.Write(header[:]); err != nil {
return 0, err
}
n, err := f.w.Write(p)
if f.flush != nil {
f.flush()
}
return n, err
}
func framed(w http.ResponseWriter) (stdout, stderr io.Writer) {
flush := func() {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
return &frameWriter{w: w, stream: streamStdout, flush: flush},
&frameWriter{w: w, stream: streamStderr, flush: flush}
}
// containerLogs streams the recorded output in docker's frame format.
func (s *Server) containerLogs(w http.ResponseWriter, r *http.Request) {
inst, ok := s.instanceOf(w, r)
if !ok {
return
}
wantOut, wantErr := boolParam(r, "stdout"), boolParam(r, "stderr")
if !wantOut && !wantErr {
writeError(w, http.StatusBadRequest, "logs needs stdout or stderr")
return
}
// Parameters that would change what the caller gets are refused
// rather than ignored: a caller who asked for the last ten lines
// and silently received ten thousand is worse off than one who was
// told no (RFC 0002).
if err := checkLogParams(r); err != nil {
writeError(w, http.StatusNotImplemented, "%v", err)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
stdout, stderr := framed(w)
if !wantOut {
stdout = io.Discard
}
if !wantErr {
stderr = io.Discard
}
stop := make(chan struct{})
done := make(chan struct{})
go func() {
select {
case <-r.Context().Done():
close(stop)
case <-done:
}
}()
defer close(done)
if err := s.Manager.Logs(inst, stdout, stderr, boolParam(r, "follow"), stop); err != nil {
s.logf("logs %s: %v", inst.ShortID(), err)
}
}
// checkLogParams refuses the log parameters werkdock cannot honour.
// Werkdock records the output as the command wrote it, without
// timestamps, so anything that selects by time or by line count has
// nothing to select on.
func checkLogParams(r *http.Request) error {
q := r.URL.Query()
if tail := q.Get("tail"); tail != "" && tail != "all" && tail != "-1" {
return fmt.Errorf("logs tail=%s is not available: werkdock streams the recorded output whole; ask for all of it and cut it yourself", tail)
}
if boolParam(r, "timestamps") {
return errors.New("logs timestamps are not available: werkdock records the output as written, without a clock beside it")
}
for _, name := range []string{"since", "until"} {
if v := q.Get(name); v != "" && v != "0" {
return fmt.Errorf("logs %s=%s is not available: werkdock records no timestamps to select by", name, v)
}
}
return nil
}
// execCreateRequest is docker's exec create body.
type execCreateRequest struct {
Cmd []string `json:"Cmd"`
Env []string `json:"Env"`
User string `json:"User"`
WorkingDir string `json:"WorkingDir"`
AttachStdout bool `json:"AttachStdout"`
AttachStderr bool `json:"AttachStderr"`
Tty bool `json:"Tty"`
Privileged bool `json:"Privileged"`
}
func (s *Server) execCreate(w http.ResponseWriter, r *http.Request) {
inst, ok := s.instanceOf(w, r)
if !ok {
return
}
var req execCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid exec body: %v", err)
return
}
if len(req.Cmd) == 0 {
writeError(w, http.StatusBadRequest, "exec needs a Cmd")
return
}
if !inst.Running() {
writeError(w, http.StatusConflict, "container %s is not running", inst.ShortID())
return
}
var env []engine.EnvVar
for _, e := range req.Env {
key, value, _ := strings.Cut(e, "=")
env = append(env, engine.EnvVar{Key: key, Value: value})
}
rec, err := s.Manager.CreateExec(inst, instance.Exec{
Cmd: req.Cmd, Env: env, Workdir: req.WorkingDir, User: req.User,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"Id": rec.ID})
}
// execStart runs the recorded command and streams its output framed.
// Docker hijacks the connection here only for an attached stdin; with
// AttachStdout/Stderr alone it is a plain response body, which is what
// a client sends (docs/testcontainers-api.md).
func (s *Server) execStart(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rec, inst, err := s.Manager.GetExec(id)
if err != nil {
if errors.Is(err, instance.ErrNotFound) {
writeError(w, http.StatusNotFound, "no such exec: %s", id)
return
}
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
var body struct {
Detach bool `json:"Detach"`
Tty bool `json:"Tty"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.Detach {
writeError(w, http.StatusNotImplemented,
"a detached exec has no instance to record it; werkdock runs an exec attached (werkdock run -d starts a second service)")
return
}
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
w.WriteHeader(http.StatusOK)
stdout, stderr := framed(w)
code, err := s.Manager.RunExec(rec, inst, &engine.Bwrap{}, stdout, stderr)
if err != nil {
s.logf("exec %s: %v", id[:12], err)
return
}
s.logf("exec %s in %s exited %d", id[:12], inst.ShortID(), code)
}
func (s *Server) execInspect(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rec, inst, err := s.Manager.GetExec(id)
if err != nil {
if errors.Is(err, instance.ErrNotFound) {
writeError(w, http.StatusNotFound, "no such exec: %s", id)
return
}
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
entrypoint := ""
var args []string
if len(rec.Cmd) > 0 {
entrypoint, args = rec.Cmd[0], rec.Cmd[1:]
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ID": rec.ID,
"ContainerID": inst.Config.ID,
"Running": rec.Running,
"ExitCode": rec.ExitCode,
"OpenStdin": false,
"OpenStdout": true,
"OpenStderr": true,
"CanRemove": true,
"DetachKeys": "",
"Pid": 0,
"ProcessConfig": map[string]interface{}{
"entrypoint": entrypoint,
"arguments": args,
"privileged": false,
"tty": false,
"user": rec.User,
},
})
}