diff --git a/internal/driver/openclaw/driver.go b/internal/driver/openclaw/driver.go index 764985c..5a0de38 100644 --- a/internal/driver/openclaw/driver.go +++ b/internal/driver/openclaw/driver.go @@ -1,7 +1,6 @@ package openclaw import ( - "bytes" "context" "fmt" "os" @@ -9,9 +8,7 @@ import ( "strings" "time" - "github.com/docker/docker/api/types" "github.com/docker/docker/client" - "github.com/docker/docker/pkg/stdcopy" "github.com/mostlydev/clawdapus/internal/driver" "github.com/mostlydev/clawdapus/internal/driver/shared" "github.com/mostlydev/clawdapus/internal/health" @@ -208,7 +205,15 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt Restart: "on-failure", SkillDir: "/claw/skills", Healthcheck: &driver.Healthcheck{ - Test: []string{"CMD", "openclaw", "health", "--json"}, + // Current OpenClaw gateways expose unauthenticated liveness and + // readiness JSON, while the CLI health RPC requires the gateway's + // runtime credential. Validate the JSON body rather than accepting an + // arbitrary 200/SPA response. The CLI fallback keeps older images, + // which predate /readyz, on their established probe path. + Test: []string{ + "CMD-SHELL", + `response="$(curl -fsS --max-time 2 http://localhost:18789/readyz 2>/dev/null)" || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e 'has("ready")' >/dev/null 2>&1 || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e '.ready == true' >/dev/null 2>&1`, + }, Interval: "30s", Timeout: "10s", Retries: 3, @@ -299,66 +304,50 @@ func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) { return &driver.Health{OK: false, Detail: fmt.Sprintf("container is not running (status: %s)", status)}, nil } - execCfg := types.ExecConfig{ - Cmd: []string{"openclaw", "health", "--json"}, - AttachStdout: true, - AttachStderr: true, - } - execID, err := cli.ContainerExecCreate(ctx, ref.ContainerID, execCfg) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("exec create failed: %v", err)}, nil - } - - resp, err := cli.ContainerExecAttach(ctx, execID.ID, types.ExecStartCheck{}) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("exec attach failed: %v", err)}, nil - } - defer resp.Close() - - var stdoutBuf bytes.Buffer - var stderrBuf bytes.Buffer - copyDone := make(chan error, 1) - go func() { - _, copyErr := stdcopy.StdCopy(&stdoutBuf, &stderrBuf, resp.Reader) - copyDone <- copyErr - }() - - select { - case copyErr := <-copyDone: - if copyErr != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("exec read failed: %v", copyErr)}, nil + // Current gateways expose /readyz without the ephemeral gateway token that + // the CLI health RPC requires. A failed request or an unrecognized response + // falls back to the CLI for compatibility with older OpenClaw images. A + // valid ready:false response is authoritative and must not be masked. + stdout, _, exitCode, execErr := shared.ExecInContainer(ctx, cli, ref.ContainerID, []string{ + "curl", "-fsS", "--max-time", "2", "http://localhost:18789/readyz", + }) + if execErr == nil && exitCode == 0 { + if result, parseErr := health.ParseOpenClawReadinessJSON([]byte(stdout)); parseErr == nil { + return &driver.Health{OK: result.OK, Detail: result.Detail}, nil } - case <-ctx.Done(): - resp.Close() - return &driver.Health{OK: false, Detail: "health probe timed out after 15s"}, nil } - execInspect, err := cli.ContainerExecInspect(ctx, execID.ID) + stdout, stderr, exitCode, err := shared.ExecInContainer(ctx, cli, ref.ContainerID, []string{ + "openclaw", "health", "--json", + }) if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("exec inspect failed: %v", err)}, nil + if ctx.Err() != nil { + return &driver.Health{OK: false, Detail: "health probe timed out after 15s"}, nil + } + return &driver.Health{OK: false, Detail: err.Error()}, nil } - if execInspect.ExitCode != 0 { - detail := strings.TrimSpace(stderrBuf.String()) + if exitCode != 0 { + detail := strings.TrimSpace(stderr) if detail == "" { - detail = strings.TrimSpace(stdoutBuf.String()) + detail = strings.TrimSpace(stdout) } if detail == "" { detail = "health command failed with no output" } - return &driver.Health{OK: false, Detail: fmt.Sprintf("health command exit code %d: %s", execInspect.ExitCode, detail)}, nil + return &driver.Health{OK: false, Detail: fmt.Sprintf("health command exit code %d: %s", exitCode, detail)}, nil } - result, err := health.ParseHealthJSON(stdoutBuf.Bytes()) + result, err := health.ParseHealthJSON([]byte(stdout)) if err != nil { detail := fmt.Sprintf("parse failed: %v", err) - if stderr := strings.TrimSpace(stderrBuf.String()); stderr != "" { + if stderr = strings.TrimSpace(stderr); stderr != "" { detail += fmt.Sprintf(" (stderr: %s)", stderr) } return &driver.Health{OK: false, Detail: detail}, nil } detail := result.Detail - if stderr := strings.TrimSpace(stderrBuf.String()); stderr != "" { + if stderr = strings.TrimSpace(stderr); stderr != "" { detail += fmt.Sprintf(" (stderr: %s)", stderr) } return &driver.Health{OK: result.OK, Detail: detail}, nil diff --git a/internal/driver/openclaw/driver_test.go b/internal/driver/openclaw/driver_test.go index 46e996b..0ef11f0 100644 --- a/internal/driver/openclaw/driver_test.go +++ b/internal/driver/openclaw/driver_test.go @@ -3,6 +3,7 @@ package openclaw import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -143,6 +144,17 @@ func TestMaterializeWritesConfigAndReturnsResult(t *testing.T) { t.Errorf("expected restart=on-failure, got %q", result.Restart) } + wantHealthcheck := []string{ + "CMD-SHELL", + `response="$(curl -fsS --max-time 2 http://localhost:18789/readyz 2>/dev/null)" || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e 'has("ready")' >/dev/null 2>&1 || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e '.ready == true' >/dev/null 2>&1`, + } + if result.Healthcheck == nil { + t.Fatal("expected OpenClaw healthcheck") + } + if got := result.Healthcheck.Test; !reflect.DeepEqual(got, wantHealthcheck) { + t.Fatalf("healthcheck test = %#v, want %#v", got, wantHealthcheck) + } + foundMemoryMount := false for _, mount := range result.Mounts { if mount.ContainerPath == shared.PortableMemoryDir { diff --git a/internal/health/openclaw.go b/internal/health/openclaw.go index e84faba..9467d8a 100644 --- a/internal/health/openclaw.go +++ b/internal/health/openclaw.go @@ -19,19 +19,51 @@ type openclawHealth struct { Version string `json:"version"` } +// openclawReadiness is the JSON structure from the gateway's /readyz endpoint. +// Ready is a pointer so a response from an unrelated endpoint cannot be mistaken +// for a valid readiness result. +type openclawReadiness struct { + Ready *bool `json:"ready"` + Failing json.RawMessage `json:"failing"` +} + +// ParseOpenClawReadinessJSON extracts readiness from the gateway's /readyz +// response. The endpoint does not require the runtime-generated gateway token. +func ParseOpenClawReadinessJSON(stdout []byte) (*HealthResult, error) { + jsonBytes, err := firstJSONObject(stdout) + if err != nil { + return nil, err + } + + var readiness openclawReadiness + if err := json.Unmarshal(jsonBytes, &readiness); err != nil { + return nil, fmt.Errorf("health probe: failed to parse readiness JSON: %w", err) + } + if readiness.Ready == nil { + return nil, fmt.Errorf("health probe: readiness JSON has no ready field") + } + + detail := "gateway ready" + if !*readiness.Ready { + detail = "gateway not ready" + failing := strings.TrimSpace(string(readiness.Failing)) + if failing != "" && failing != "null" && failing != "[]" { + detail += ": " + failing + } + } + + return &HealthResult{OK: *readiness.Ready, Detail: detail}, nil +} + // ParseHealthJSON extracts health status from stdout bytes. // Handles leading noise by scanning for the first '{' character. func ParseHealthJSON(stdout []byte) (*HealthResult, error) { - s := string(stdout) - - idx := strings.Index(s, "{") - if idx < 0 { - return nil, fmt.Errorf("health probe: no JSON object found in output") + jsonBytes, err := firstJSONObject(stdout) + if err != nil { + return nil, err } - - jsonStr := s[idx:] var h openclawHealth - if err := json.Unmarshal([]byte(jsonStr), &h); err != nil { + if err := json.Unmarshal(jsonBytes, &h); err != nil { return nil, fmt.Errorf("health probe: failed to parse JSON: %w", err) } @@ -40,3 +72,11 @@ func ParseHealthJSON(stdout []byte) (*HealthResult, error) { Detail: h.Detail, }, nil } + +func firstJSONObject(stdout []byte) ([]byte, error) { + idx := strings.IndexByte(string(stdout), '{') + if idx < 0 { + return nil, fmt.Errorf("health probe: no JSON object found in output") + } + return stdout[idx:], nil +} diff --git a/internal/health/openclaw_test.go b/internal/health/openclaw_test.go index b54cc30..3fdadb9 100644 --- a/internal/health/openclaw_test.go +++ b/internal/health/openclaw_test.go @@ -2,6 +2,39 @@ package health import "testing" +func TestParseOpenClawReadinessJSONReady(t *testing.T) { + result, err := ParseOpenClawReadinessJSON([]byte(`{"ready":true,"failing":[]}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.OK { + t.Error("expected OK=true") + } + if result.Detail != "gateway ready" { + t.Errorf("expected gateway-ready detail, got %q", result.Detail) + } +} + +func TestParseOpenClawReadinessJSONNotReady(t *testing.T) { + result, err := ParseOpenClawReadinessJSON([]byte(`{"ready":false,"failing":["discord"]}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.OK { + t.Error("expected OK=false") + } + if result.Detail != `gateway not ready: ["discord"]` { + t.Errorf("expected failing-component detail, got %q", result.Detail) + } +} + +func TestParseOpenClawReadinessJSONRequiresReadyField(t *testing.T) { + _, err := ParseOpenClawReadinessJSON([]byte(`{"status":"ok"}`)) + if err == nil { + t.Fatal("expected error when readiness field is absent") + } +} + func TestParseHealthJSONClean(t *testing.T) { stdout := `{"status":"ok","version":"2026.2.9"}` result, err := ParseHealthJSON([]byte(stdout))