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() }