instancectl client, LayoutJournalPath, ReadBeneathLimit (plan 3, task 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+353
−4
internal/instancectl/client.go+166 −0internal/instancectl/client_test.go+121 −0internal/instancectl/server_test.go+13 −4internal/instancefs/instancefs.go+24 −0internal/instancefs/instancefs_linux_test.go+14 −0internal/provision/provision.go+9 −0internal/provision/provision_test.go+6 −0
CI passed — run details
diff --git a/internal/instancectl/client.go b/internal/instancectl/client.go| new file mode 100644 |
| index 000000000..689b08eb0 |
| --- /dev/null |
| +++ b/internal/instancectl/client.go |
| @@ -0,0 +1,166 @@ |
| +package instancectl |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Deadlines per verb (design §5.3). A start or stop that exceeds its |
| +// deadline is a failed wake or an unproven stop, never a hang: §6.7 puts |
| +// one such call on a path that must not stall the activator. The seed's is |
| +// long because a seed is allowed to be slow. |
| +const ( |
| + DeadlineControl = 30 * time.Second |
| + DeadlineSeed = 150 * time.Second |
| + DeadlineSweep = 60 * time.Second |
| + DeadlineReady = 5 * time.Second |
| +) |
| + |
| +// Client is the agent's side of the control channel. It is plain HTTP over |
| +// the unix socket at Path; unixdial's checks are for UPSTREAMS the edge |
| +// proxies to, and this socket is the one path they refuse, so the client |
| +// dials it directly. |
| +type Client struct { |
| + Path string |
| + // Dial is the dialer; nil means net.Dialer. Tests do not need to override |
| + // it, but a caller composing several clients may. |
| + Dial func(ctx context.Context, path string) (net.Conn, error) |
| +} |
| + |
| +func NewClient(path string) *Client { return &Client{Path: path} } |
| + |
| +// NotReadyError carries the probe verdict a 503 /ready returned. |
| +type NotReadyError struct{ Report ProbeReport } |
| + |
| +func (e *NotReadyError) Error() string { |
| + var fails []string |
| + for _, l := range e.Report.Lines { |
| + if l.Verdict != "ok" { |
| + fails = append(fails, l.Check+" "+l.Verdict+" "+l.Detail) |
| + } |
| + } |
| + if len(fails) == 0 { |
| + return "instance control: box not ready (no verdict yet)" |
| + } |
| + return "instance control: box not ready: " + strings.Join(fails, "; ") |
| +} |
| + |
| +func (c *Client) httpClient() *http.Client { |
| + dial := c.Dial |
| + if dial == nil { |
| + dial = func(ctx context.Context, path string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", path) |
| + } |
| + } |
| + return &http.Client{Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dial(ctx, c.Path) }, |
| + DisableKeepAlives: true, |
| + }} |
| +} |
| + |
| +// do issues one verb with the given deadline and decodes a JSON body when |
| +// there is one. A non-2xx answer becomes an error carrying the server's |
| +// message; a dial failure names the socket so a forgotten enable reads as |
| +// what it is from carlos logs. |
| +func (c *Client) do(ctx context.Context, deadline time.Duration, method, path string, q url.Values, out any) (int, error) { |
| + ctx, cancel := context.WithTimeout(ctx, deadline) |
| + defer cancel() |
| + u := "http://control" + path |
| + if len(q) > 0 { |
| + u += "?" + q.Encode() |
| + } |
| + req, err := http.NewRequestWithContext(ctx, method, u, nil) |
| + if err != nil { |
| + return 0, err |
| + } |
| + resp, err := c.httpClient().Do(req) |
| + if err != nil { |
| + return 0, fmt.Errorf("instance control socket %s: %w", c.Path, err) |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) |
| + if resp.StatusCode == http.StatusServiceUnavailable && path == "/ready" { |
| + var r ProbeReport |
| + _ = json.Unmarshal(body, &r) |
| + return resp.StatusCode, &NotReadyError{Report: r} |
| + } |
| + // A 502 from /sweep still carries the units it did and didn't manage to |
| + // stop, so it is decoded into out here even though it is about to be |
| + // reported as an error too; the caller needs the partial list alongside |
| + // the failure, not instead of it. |
| + if out != nil && len(body) > 0 && (resp.StatusCode/100 == 2 || (resp.StatusCode == http.StatusBadGateway && path == "/sweep")) { |
| + if err := json.Unmarshal(body, out); err != nil { |
| + return resp.StatusCode, fmt.Errorf("instance control %s: %v", path, err) |
| + } |
| + } |
| + if resp.StatusCode/100 != 2 { |
| + return resp.StatusCode, fmt.Errorf("instance control %s: %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body))) |
| + } |
| + return resp.StatusCode, nil |
| +} |
| + |
| +func hostRole(host string, role Role) url.Values { |
| + q := url.Values{"host": {host}} |
| + if role != "" { |
| + q.Set("role", string(role)) |
| + } |
| + return q |
| +} |
| + |
| +// Start starts the run or sidecar unit for host with the memory tier and |
| +// returns the invocation id the server read after the start. |
| +func (c *Client) Start(ctx context.Context, host string, role Role, memoryMax int64) (string, error) { |
| + q := hostRole(host, role) |
| + q.Set("memory_max", strconv.FormatInt(memoryMax, 10)) |
| + var out StartResponse |
| + if _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/start", q, &out); err != nil { |
| + return "", err |
| + } |
| + return out.InvocationID, nil |
| +} |
| + |
| +// Seed runs the seed unit to completion and returns its retained result. |
| +func (c *Client) Seed(ctx context.Context, host string, memoryMax int64) (SeedResponse, error) { |
| + q := hostRole(host, "") |
| + q.Set("memory_max", strconv.FormatInt(memoryMax, 10)) |
| + var out SeedResponse |
| + _, err := c.do(ctx, DeadlineSeed, http.MethodPost, "/seed", q, &out) |
| + return out, err |
| +} |
| + |
| +func (c *Client) Stop(ctx context.Context, host string, role Role) error { |
| + _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/stop", hostRole(host, role), nil) |
| + return err |
| +} |
| + |
| +func (c *Client) Reset(ctx context.Context, host string, role Role) error { |
| + _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/reset", hostRole(host, role), nil) |
| + return err |
| +} |
| + |
| +// Sweep stops every instance unit on the box (design §6.7). A 502 carries |
| +// the units that were stopped and the ones that would not; both are |
| +// returned with the error. |
| +func (c *Client) Sweep(ctx context.Context) (SweepResponse, error) { |
| + var out SweepResponse |
| + status, err := c.do(ctx, DeadlineSweep, http.MethodPost, "/sweep", nil, &out) |
| + if err != nil && status == http.StatusBadGateway { |
| + return out, errors.Join(err, errors.New("some instance units would not stop")) |
| + } |
| + return out, err |
| +} |
| + |
| +// Ready is nil on 204 and a *NotReadyError on 503. |
| +func (c *Client) Ready(ctx context.Context) error { |
| + _, err := c.do(ctx, DeadlineReady, http.MethodGet, "/ready", nil, nil) |
| + return err |
| +} |
diff --git a/internal/instancectl/client_test.go b/internal/instancectl/client_test.go| new file mode 100644 |
| index 000000000..dc84c5837 |
| --- /dev/null |
| +++ b/internal/instancectl/client_test.go |
| @@ -0,0 +1,121 @@ |
| +package instancectl |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// newClient serves a Server on a real unix socket with the given fake |
| +// systemctl and returns the agent-side Client for it. |
| +func newClient(t *testing.T, ctl *fakeCtl, configure ...func(*Server)) (*Server, *Client) { |
| + t.Helper() |
| + s, sock := serveOnSocket(t, ctl, configure...) |
| + return s, NewClient(sock) |
| +} |
| + |
| +func TestClientStartCarriesTierAndReturnsInvocation(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{"show -p InvocationID --value": "inv1\n"}} |
| + _, c := newClient(t, ctl) |
| + inv, err := c.Start(context.Background(), "h.example.com", RoleRun, 536870912) |
| + if err != nil || inv != "inv1" { |
| + t.Fatalf("Start = %q, %v", inv, err) |
| + } |
| + if !strings.Contains(ctl.joined(), "MemoryMax=536870912") { |
| + t.Errorf("tier not sent: %s", ctl.joined()) |
| + } |
| + if !strings.Contains(ctl.joined(), "start "+UnitName(RoleRun, "h.example.com")) { |
| + t.Errorf("run unit not started: %s", ctl.joined()) |
| + } |
| +} |
| + |
| +func TestClientSeedStopResetSweep(t *testing.T) { |
| + ctl := &fakeCtl{answers: map[string]string{ |
| + "show -p InvocationID,Result,ExecMainStatus,ExecMainCode": "InvocationID=s1\nResult=success\nExecMainStatus=0\nExecMainCode=exited\n", |
| + }} |
| + _, c := newClient(t, ctl) |
| + res, err := c.Seed(context.Background(), "h.example.com", 536870912) |
| + if err != nil || res.Result != "success" || res.InvocationID != "s1" || res.Status != "0" || res.Code != "exited" { |
| + t.Fatalf("Seed = %+v, %v", res, err) |
| + } |
| + if err := c.Stop(context.Background(), "h.example.com", RoleSidecar); err != nil { |
| + t.Fatalf("Stop: %v", err) |
| + } |
| + if !strings.Contains(ctl.joined(), "stop "+UnitName(RoleSidecar, "h.example.com")) { |
| + t.Errorf("sidecar unit not stopped: %s", ctl.joined()) |
| + } |
| + if err := c.Reset(context.Background(), "h.example.com", RoleRun); err != nil { |
| + t.Fatalf("Reset: %v", err) |
| + } |
| + sw, err := c.Sweep(context.Background()) |
| + if err != nil || len(sw.Units) != 0 || len(sw.Failed) != 0 { |
| + t.Fatalf("Sweep = %+v, %v", sw, err) |
| + } |
| +} |
| + |
| +func TestClientReadyIsNilOn204AndCarriesTheReportOn503(t *testing.T) { |
| + ctl := &fakeCtl{} |
| + s, c := newClient(t, ctl) |
| + // Before the first probe the server answers 503 with one line saying |
| + // so; the client surfaces it as a NotReadyError whose message names |
| + // the failing line. |
| + err := c.Ready(context.Background()) |
| + var nr *NotReadyError |
| + if !errors.As(err, &nr) || len(nr.Report.Lines) != 1 || nr.Report.Lines[0].Check != "probe" { |
| + t.Fatalf("Ready before a probe = %v", err) |
| + } |
| + if !strings.Contains(err.Error(), "not yet run") { |
| + t.Errorf("message = %q", err.Error()) |
| + } |
| + b, _ := json.Marshal(ProbeReport{Ready: true, Lines: []VerdictLine{{Check: "pid1", Verdict: "ok"}}}) |
| + if err := os.WriteFile(s.ProbeOut, b, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.RunProbe(context.Background()) |
| + if err := c.Ready(context.Background()); err != nil { |
| + t.Fatalf("Ready after a passing probe: %v", err) |
| + } |
| +} |
| + |
| +func TestClientSurfacesTheServersMessageOnA502(t *testing.T) { |
| + ctl := &fakeCtl{fail: map[string]error{"start": errors.New("boom")}} |
| + _, c := newClient(t, ctl) |
| + _, err := c.Start(context.Background(), "h.example.com", RoleRun, 536870912) |
| + if err == nil || !strings.Contains(err.Error(), "boom") || !strings.Contains(err.Error(), "/start") { |
| + t.Fatalf("Start error = %v, want the server's message and the verb", err) |
| + } |
| +} |
| + |
| +func TestClientSweepReturnsThePartialListWithTheError(t *testing.T) { |
| + // One unit is listed, its stop fails, and the server answers 502 with |
| + // the partial result; the client returns both. |
| + unit := UnitName(RoleRun, "h.example.com") |
| + ctl := &fakeCtl{ |
| + answers: map[string]string{ |
| + "list-units": unit + " loaded active running carlos instance\n", |
| + }, |
| + fail: map[string]error{"stop": errors.New("would not stop")}, |
| + } |
| + _, c := newClient(t, ctl) |
| + sw, err := c.Sweep(context.Background()) |
| + if err == nil { |
| + t.Fatalf("Sweep = %+v, want an error", sw) |
| + } |
| + if _, listed := sw.Failed[unit]; !listed { |
| + t.Errorf("Failed = %+v, want %s", sw.Failed, unit) |
| + } |
| +} |
| + |
| +func TestClientRefusesWhenTheSocketIsAbsent(t *testing.T) { |
| + c := NewClient(filepath.Join(t.TempDir(), "absent.sock")) |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| + defer cancel() |
| + if err := c.Ready(ctx); err == nil || !strings.Contains(err.Error(), "instance control socket") { |
| + t.Fatalf("absent socket: %v", err) |
| + } |
| +} |
diff --git a/internal/instancectl/server_test.go b/internal/instancectl/server_test.go| index 2ccc339ee..0ed37a3f6 100644 |
| --- a/internal/instancectl/server_test.go |
| +++ b/internal/instancectl/server_test.go |
| @@ -74,6 +74,18 @@ func (f *fakeCtl) joined() string { |
| // PeerOK (or anything else) sets it up front rather than racing Serve's |
| // first Accept (codex review fix round 2 item 1). |
| func newServer(t *testing.T, ctl *fakeCtl, configure ...func(*Server)) (*Server, *http.Client) { |
| + t.Helper() |
| + s, sock := serveOnSocket(t, ctl, configure...) |
| + client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", sock) |
| + }}} |
| + return s, client |
| +} |
| + |
| +// serveOnSocket is newServer without the http.Client: it returns the |
| +// socket path so a test of the agent-side Client can dial it the way |
| +// production does. |
| +func serveOnSocket(t *testing.T, ctl *fakeCtl, configure ...func(*Server)) (*Server, string) { |
| t.Helper() |
| root := t.TempDir() |
| s := &Server{ |
| @@ -102,10 +114,7 @@ func newServer(t *testing.T, ctl *fakeCtl, configure ...func(*Server)) (*Server, |
| t.Cleanup(cancel) |
| go s.Serve(ctx, ln) //nolint:errcheck |
| t.Cleanup(func() { ln.Close() }) |
| - client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| - return (&net.Dialer{}).DialContext(ctx, "unix", sock) |
| - }}} |
| - return s, client |
| + return s, sock |
| } |
| func post(t *testing.T, c *http.Client, path string) *http.Response { |
diff --git a/internal/instancefs/instancefs.go b/internal/instancefs/instancefs.go| index de8ec6824..acad5dcff 100644 |
| --- a/internal/instancefs/instancefs.go |
| +++ b/internal/instancefs/instancefs.go |
| @@ -21,6 +21,7 @@ package instancefs |
| import ( |
| "errors" |
| + "fmt" |
| "io" |
| "os" |
| "path/filepath" |
| @@ -54,3 +55,26 @@ func ReadBeneath(dir, name string) ([]byte, error) { |
| defer f.Close() |
| return io.ReadAll(f) |
| } |
| + |
| +// ErrTooLarge is returned by ReadBeneathLimit when the file exceeds max. |
| +var ErrTooLarge = errors.New("instancefs: file larger than the limit") |
| + |
| +// ReadBeneathLimit is ReadBeneath with a size cap. Files in an instance |
| +// directory are tenant-writable, and some are read by the agent outside the |
| +// tenant's memory wall (the exit record, the launch file), so no read there |
| +// is unbounded. |
| +func ReadBeneathLimit(dir, name string, max int64) ([]byte, error) { |
| + f, err := OpenBeneath(dir, name, os.O_RDONLY, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer f.Close() |
| + b, err := io.ReadAll(io.LimitReader(f, max+1)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if int64(len(b)) > max { |
| + return nil, fmt.Errorf("%w: %s over %d bytes", ErrTooLarge, filepath.Join(dir, name), max) |
| + } |
| + return b, nil |
| +} |
diff --git a/internal/instancefs/instancefs_linux_test.go b/internal/instancefs/instancefs_linux_test.go| index cf702a578..674a2a73d 100644 |
| --- a/internal/instancefs/instancefs_linux_test.go |
| +++ b/internal/instancefs/instancefs_linux_test.go |
| @@ -193,3 +193,17 @@ func TestMountOptionOfIsTheGeneralForm(t *testing.T) { |
| } |
| _ = got // / is not reliably ro or rw across test hosts; only the absence of an error matters here. |
| } |
| + |
| +func TestReadBeneathLimitRefusesALargeFile(t *testing.T) { |
| + dir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(dir, "big"), make([]byte, 4096), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadBeneathLimit(dir, "big", 1024); !errors.Is(err, ErrTooLarge) { |
| + t.Fatalf("err = %v, want ErrTooLarge", err) |
| + } |
| + got, err := ReadBeneathLimit(dir, "big", 4096) |
| + if err != nil || len(got) != 4096 { |
| + t.Fatalf("exact limit: %d bytes, %v", len(got), err) |
| + } |
| +} |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index aa2dcfe96..525201223 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1091,6 +1091,15 @@ func LogSocket(host, role string) string { |
| return filepath.Join(SocketDir, "logs", InstanceID(host)+"."+role+".sock") |
| } |
| +// LayoutJournalPath is the migration journal for a host in flight |
| +// (isolated-exec design §8.2): under <dataDir>/layout, outside every tenant |
| +// mount. Its presence is what makes a host `fenced` (§8.1); plan 4 writes |
| +// it, and every consumer that must not act on a half-migrated host checks |
| +// for it here. |
| +func LayoutJournalPath(dataDir, host string) string { |
| + return filepath.Join(dataDir, "layout", host+".json") |
| +} |
| + |
| // IsControlEndpoint reports whether path is the control channel or |
| // anything else under its directory. The edge and the agent refuse to |
| // dial such a path as an upstream (design §5.4): a registry row pointing |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 595b52611..1ab77449c 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -1109,3 +1109,9 @@ func TestIsControlEndpoint(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestLayoutJournalPath(t *testing.T) { |
| + if got := LayoutJournalPath("/data/", "h.example.com"); got != "/data/layout/h.example.com.json" { |
| + t.Errorf("LayoutJournalPath = %q", got) |
| + } |
| +} |