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

233 lines
7.2 KiB
Go

package api
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"werkdock/internal/instance"
"werkdock/internal/store"
)
// The whole API against a real sandbox, over HTTP, the way a client
// drives it: create, start, logs, exec, kill, delete. This is what
// keeps the API honest in CI, where no Testcontainers run happens
// (the external one lives in the werkprobe repository).
func TestTheWholeLifecycleOverHTTP(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
home := t.TempDir()
// An image that is empty but for its mountpoints; the host's
// toolchain directories are bound in per container, as binds.
dir := filepath.Join(home, "images", "host", "latest")
for _, d := range []string{"rootfs/etc", "rootfs/mnt"} {
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
t.Fatal(err)
}
}
meta := store.ImageMeta{Name: "host:latest", Architecture: "amd64", OS: "linux",
Config: store.ImageConfig{Cmd: []string{"sh"}, Volumes: map[string]struct{}{"/mnt": {}}}}
data, _ := json.Marshal(meta)
if err := os.WriteFile(filepath.Join(dir, "image.json"), data, 0o644); err != nil {
t.Fatal(err)
}
var binds []string
for _, d := range []string{"/bin", "/sbin", "/lib", "/lib64", "/usr"} {
if _, err := os.Stat(d); err == nil {
binds = append(binds, d+":"+d+":ro")
}
}
// The supervisor is the werkdock binary, so the API needs one.
werkdock := filepath.Join(t.TempDir(), "werkdock")
if out, err := exec.Command("go", "build", "-o", werkdock, "werkdock").CombinedOutput(); err != nil {
t.Fatalf("go build: %v\n%s", err, out)
}
server := &Server{
Store: store.Store{Root: home},
Manager: instance.Manager{Root: home},
Executable: werkdock,
Version: "test",
}
http := httptest.NewServer(server.Handler())
defer http.Close()
c := &client{t: t, base: http.URL}
bindsJSON, _ := json.Marshal(binds)
create := fmt.Sprintf(`{"Image":"host","Cmd":["sh","-c","echo hello > /mnt/marker; echo up; echo warned >&2; while :; do sleep 0.1; done"],
"Labels":{"org.testcontainers":"true"},"HostConfig":{"Binds":%s}}`, bindsJSON)
id, _ := c.post("/containers/create", create, 201)["Id"].(string)
if len(id) != 64 {
t.Fatalf("id %q", id)
}
c.postStatus("/containers/"+id+"/start", "", 204)
// If an assertion below fails, the sandbox must not outlive the test.
defer c.cleanup("/containers/" + id + "?v=true&force=true")
// the log stream carries both streams, framed, and the wait
// strategy of a real client reads exactly this
var stdout, stderr string
deadline := time.Now().Add(15 * time.Second)
for {
stdout, stderr = c.logs("/containers/" + id + "/logs?stdout=true&stderr=true")
if strings.Contains(stdout, "up") && strings.Contains(stderr, "warned") {
break
}
if time.Now().After(deadline) {
t.Fatalf("the container never spoke: stdout %q, stderr %q", stdout, stderr)
}
time.Sleep(200 * time.Millisecond)
}
body := c.get("/containers/"+id+"/json", 200)
state, _ := body["State"].(map[string]interface{})
if running, _ := state["Running"].(bool); !running {
t.Errorf("state: %v", state)
}
// exec shares the instance's anonymous volume
execID, _ := c.post("/containers/"+id+"/exec", `{"Cmd":["cat","/mnt/marker"],"AttachStdout":true,"AttachStderr":true}`, 201)["Id"].(string)
out, _ := c.logs("/exec/" + execID + "/start")
if strings.TrimSpace(out) != "hello" {
t.Errorf("exec output %q", out)
}
inspect := c.get("/exec/"+execID+"/json", 200)
if code, _ := inspect["ExitCode"].(float64); code != 0 {
t.Errorf("exec exit code %v", code)
}
if running, _ := inspect["Running"].(bool); running {
t.Errorf("the exec is over and should say so")
}
// the label filter is how a client finds what it made
list := c.getList(`/containers/json?all=true&filters={"label":["org.testcontainers=true"]}`, 200)
if len(list) != 1 {
t.Fatalf("label filter: %v", list)
}
c.postStatus("/containers/"+id+"/kill", "", 204)
waited := c.post("/containers/"+id+"/wait", "", 200)
if _, ok := waited["StatusCode"]; !ok {
t.Errorf("wait: %v", waited)
}
c.deleteStatus("/containers/"+id+"?v=true&force=true", 204)
if len(c.getList("/containers/json?all=true", 200)) != 0 {
t.Errorf("the container outlived its removal")
}
if entries, _ := os.ReadDir(filepath.Join(home, "instances")); len(entries) != 0 {
t.Errorf("instance directories left: %v", entries)
}
}
// client is the small amount of HTTP a test needs.
type client struct {
t *testing.T
base string
}
func (c *client) do(method, path, body string, want int) []byte {
c.t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req, err := http.NewRequest(method, c.base+"/v1.32"+path, reader)
if err != nil {
c.t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
c.t.Fatalf("%s %s: %d, want %d: %s", method, path, resp.StatusCode, want, data)
}
return data
}
func (c *client) post(path, body string, want int) map[string]interface{} {
c.t.Helper()
var out map[string]interface{}
if data := c.do("POST", path, body, want); len(data) > 0 {
json.Unmarshal(data, &out)
}
return out
}
func (c *client) postStatus(path, body string, want int) {
c.t.Helper()
c.do("POST", path, body, want)
}
func (c *client) deleteStatus(path string, want int) { c.t.Helper(); c.do("DELETE", path, "", want) }
// cleanup removes what is left, if anything is: best effort, no
// assertions, so it can follow a successful removal as well as a
// failed one.
func (c *client) cleanup(path string) {
req, err := http.NewRequest("DELETE", c.base+"/v1.32"+path, nil)
if err != nil {
return
}
if resp, err := http.DefaultClient.Do(req); err == nil {
resp.Body.Close()
}
}
func (c *client) get(path string, want int) map[string]interface{} {
c.t.Helper()
var out map[string]interface{}
json.Unmarshal(c.do("GET", path, "", want), &out)
return out
}
func (c *client) getList(path string, want int) []map[string]interface{} {
c.t.Helper()
var out []map[string]interface{}
json.Unmarshal(c.do("GET", path, "", want), &out)
return out
}
// logs reads docker's multiplexed stream back into two strings.
func (c *client) logs(path string) (stdout, stderr string) {
c.t.Helper()
method := "GET"
body := ""
if strings.HasSuffix(path, "/start") {
method, body = "POST", `{"Detach":false,"Tty":false}`
}
raw := c.do(method, path, body, 200)
var out, errs strings.Builder
for len(raw) >= 8 {
length := int(binary.BigEndian.Uint32(raw[4:8]))
if len(raw) < 8+length {
c.t.Fatalf("truncated frame: %d bytes left, %d announced", len(raw)-8, length)
}
payload := string(raw[8 : 8+length])
switch raw[0] {
case streamStdout:
out.WriteString(payload)
case streamStderr:
errs.WriteString(payload)
default:
c.t.Fatalf("unknown stream %d", raw[0])
}
raw = raw[8+length:]
}
return out.String(), errs.String()
}