carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package instancectl
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/version"
17)
18
19// Deadlines per verb (design §5.3). A start or stop that exceeds its
20// deadline is a failed wake or an unproven stop, never a hang: §6.7 puts
21// one such call on a path that must not stall the activator. The seed's is
22// long because a seed is allowed to be slow.
23const (
24 DeadlineControl = 30 * time.Second
25 DeadlineSeed = 150 * time.Second
26 DeadlineSweep = 60 * time.Second
27 DeadlineReady = 5 * time.Second
28
29 // DeadlineCmdPoll bounds GET /cmd/{host}; the server answers within 30 s.
30 DeadlineCmdPoll = 35 * time.Second
31)
32
33// Client is the agent's side of the control channel. It is plain HTTP over
34// the unix socket at Path; unixdial's checks are for UPSTREAMS the edge
35// proxies to, and this socket is the one path they refuse, so the client
36// dials it directly.
37type Client struct {
38 Path string
39 // Dial is the dialer; nil means net.Dialer. Tests do not need to override
40 // it, but a caller composing several clients may.
41 Dial func(ctx context.Context, path string) (net.Conn, error)
42 // Build is the build this agent runs. Ready refuses a service whose
43 // BuildHeader does not name it (#256); empty skips the comparison,
44 // for tests that build a Client literal. NewClient sets version.Build.
45 Build string
46}
47
48func NewClient(path string) *Client { return &Client{Path: path, Build: version.Build} }
49
50// StaleBuildError is Ready's answer to a control service running a build
51// other than the agent's own: the service outlived a binary change and
52// serves this release's units with the previous release's code (#256).
53type StaleBuildError struct{ Running, Want string }
54
55func (e *StaleBuildError) Error() string {
56 if e.Running == "" {
57 return fmt.Sprintf("instance control: carlos-instance-control.service is running an older build than this agent (%s); restart carlos-instance-control.service", e.Want)
58 }
59 return fmt.Sprintf("instance control: carlos-instance-control.service is running build %s, this agent is %s; restart carlos-instance-control.service", e.Running, e.Want)
60}
61
62// NotReadyError carries the probe verdict a 503 /ready returned.
63type NotReadyError struct{ Report ProbeReport }
64
65func (e *NotReadyError) Error() string {
66 var fails []string
67 for _, l := range e.Report.Lines {
68 if l.Verdict != "ok" {
69 fails = append(fails, l.Check+" "+l.Verdict+" "+l.Detail)
70 }
71 }
72 if len(fails) == 0 {
73 return "instance control: box not ready (no verdict yet)"
74 }
75 return "instance control: box not ready: " + strings.Join(fails, "; ")
76}
77
78// httpClient builds a fresh Transport per call rather than caching one on c.
79// DisableKeepAlives is what makes that the right trade: a pooled idle
80// connection nothing ever reuses (each call already builds its own client)
81// would just sit open, leaking one file descriptor per call instead of
82// closing when the response is read.
83func (c *Client) httpClient() *http.Client {
84 dial := c.Dial
85 if dial == nil {
86 dial = func(ctx context.Context, path string) (net.Conn, error) {
87 return (&net.Dialer{}).DialContext(ctx, "unix", path)
88 }
89 }
90 return &http.Client{Transport: &http.Transport{
91 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dial(ctx, c.Path) },
92 DisableKeepAlives: true,
93 }}
94}
95
96// do issues one verb with the given deadline and decodes a JSON body when
97// there is one. A non-2xx answer becomes an error carrying the server's
98// message; a dial failure names the socket so a forgotten enable reads as
99// what it is from carlos logs.
100func (c *Client) do(ctx context.Context, deadline time.Duration, method, path string, q url.Values, out any) (int, http.Header, error) {
101 ctx, cancel := context.WithTimeout(ctx, deadline)
102 defer cancel()
103 u := "http://control" + path
104 if len(q) > 0 {
105 u += "?" + q.Encode()
106 }
107 req, err := http.NewRequestWithContext(ctx, method, u, nil)
108 if err != nil {
109 return 0, nil, err
110 }
111 resp, err := c.httpClient().Do(req)
112 if err != nil {
113 return 0, nil, fmt.Errorf("instance control socket %s: %w", c.Path, err)
114 }
115 defer resp.Body.Close()
116 body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
117 if resp.StatusCode == http.StatusServiceUnavailable && path == "/ready" {
118 var r ProbeReport
119 _ = json.Unmarshal(body, &r)
120 return resp.StatusCode, resp.Header, &NotReadyError{Report: r}
121 }
122 // A 502 from /sweep still carries the units it did and didn't manage to
123 // stop, so it is decoded into out here even though it is about to be
124 // reported as an error too; the caller needs the partial list alongside
125 // the failure, not instead of it.
126 if out != nil && len(body) > 0 && (resp.StatusCode/100 == 2 || (resp.StatusCode == http.StatusBadGateway && path == "/sweep")) {
127 if err := json.Unmarshal(body, out); err != nil {
128 return resp.StatusCode, resp.Header, fmt.Errorf("instance control %s: %v", path, err)
129 }
130 }
131 if resp.StatusCode/100 != 2 {
132 return resp.StatusCode, resp.Header, fmt.Errorf("instance control %s: %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
133 }
134 return resp.StatusCode, resp.Header, nil
135}
136
137func hostRole(host string, role Role) url.Values {
138 q := url.Values{"host": {host}}
139 if role != "" {
140 q.Set("role", string(role))
141 }
142 return q
143}
144
145// Start starts the run or sidecar unit for host with the memory tier and
146// returns the invocation id the server read after the start.
147func (c *Client) Start(ctx context.Context, host string, role Role, memoryMax int64) (string, error) {
148 q := hostRole(host, role)
149 q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
150 var out StartResponse
151 if _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/start", q, &out); err != nil {
152 return "", err
153 }
154 return out.InvocationID, nil
155}
156
157// Seed runs the seed unit to completion and returns its retained result.
158func (c *Client) Seed(ctx context.Context, host string, memoryMax int64) (SeedResponse, error) {
159 return c.seedQuery(ctx, host, memoryMax, nil)
160}
161
162// SeedExe is Seed with the seed's executable named: the staged build the
163// wake recorded (settings spec §4.2), which root validates and binds in
164// place of the live link. A 403 is root's refusal, reported as
165// ErrStagedExe: nothing was laid out or run.
166func (c *Client) SeedExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (SeedResponse, error) {
167 return c.seedQuery(ctx, host, memoryMax, &x)
168}
169
170func (c *Client) seedQuery(ctx context.Context, host string, memoryMax int64, x *StagedExe) (SeedResponse, error) {
171 q := hostRole(host, "")
172 q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
173 if x != nil {
174 setStagedExe(q, *x)
175 }
176 var out SeedResponse
177 status, _, err := c.do(ctx, DeadlineSeed, http.MethodPost, "/seed", q, &out)
178 if err != nil && status == http.StatusForbidden && x != nil {
179 return out, fmt.Errorf("%w: %v", ErrStagedExe, err)
180 }
181 return out, err
182}
183
184// setStagedExe puts x on a start or seed query.
185func setStagedExe(q url.Values, x StagedExe) {
186 q.Set("exe", x.Path)
187 q.Set("version", x.Version)
188 q.Set("digest", x.Digest)
189}
190
191func (c *Client) Stop(ctx context.Context, host string, role Role) error {
192 _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/stop", hostRole(host, role), nil)
193 return err
194}
195
196// Cmd starts host's cmd unit and returns its invocation (run spec §5.4).
197func (c *Client) Cmd(ctx context.Context, host string, memoryMax int64) (string, error) {
198 return c.cmdQuery(ctx, host, memoryMax, nil)
199}
200
201// CmdExe is Cmd with the executable named: the serving generation's staged
202// build, which root validates and mounts in place of the live link, as for
203// the legacy sidecar (zero-downtime spec §7.5). A 403 is root's refusal,
204// reported as ErrStagedExe.
205func (c *Client) CmdExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (string, error) {
206 return c.cmdQuery(ctx, host, memoryMax, &x)
207}
208
209func (c *Client) cmdQuery(ctx context.Context, host string, memoryMax int64, x *StagedExe) (string, error) {
210 q := hostRole(host, "")
211 q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
212 if x != nil {
213 q.Set("exe", x.Path)
214 q.Set("version", x.Version)
215 q.Set("digest", x.Digest)
216 }
217 var out StartResponse
218 status, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/cmd", q, &out)
219 if err != nil {
220 if status == http.StatusForbidden && x != nil {
221 return "", fmt.Errorf("%w: %v", ErrStagedExe, err)
222 }
223 return "", err
224 }
225 return out.InvocationID, nil
226}
227
228// CmdStatus long-polls the named invocation of host's cmd unit.
229func (c *Client) CmdStatus(ctx context.Context, host, invocation string) (CmdStatus, error) {
230 var out CmdStatus
231 _, _, err := c.do(ctx, DeadlineCmdPoll, http.MethodGet, "/cmd/"+url.PathEscape(host), url.Values{"invocation": {invocation}}, &out)
232 return out, err
233}
234
235func (c *Client) Reset(ctx context.Context, host string, role Role) error {
236 _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/reset", hostRole(host, role), nil)
237 return err
238}
239
240// genValues is the query of a generation verb: the run role and gen=<n>.
241func genValues(host string, n uint64) url.Values {
242 q := hostRole(host, RoleRun)
243 q.Set("gen", strconv.FormatUint(n, 10))
244 return q
245}
246
247// StartGen starts generation n's unit (zero-downtime spec §7.6) with its
248// tier and returns the invocation id the server read after the start. A
249// failure whose cleanup could not prove the unit gone wraps ErrGenUnproven.
250func (c *Client) StartGen(ctx context.Context, host string, n uint64, memoryMax int64) (string, error) {
251 return c.startUnitQuery(ctx, genValues(host, n), memoryMax, nil)
252}
253
254// StartGenExe is StartGen with the generation's executable named: the
255// staged build root validates and mounts in place of the live link. A 403
256// is root's refusal, reported as ErrStagedExe.
257func (c *Client) StartGenExe(ctx context.Context, host string, n uint64, memoryMax int64, x StagedExe) (string, error) {
258 return c.startUnitQuery(ctx, genValues(host, n), memoryMax, &x)
259}
260
261// StartExe is Start with the unit's executable named: a staged build root
262// validates and mounts in place of the live link, for the original
263// template's run unit (the build a cold wake recorded, settings spec §4.2)
264// or the legacy sidecar. A 403 is root's refusal, reported as
265// ErrStagedExe.
266func (c *Client) StartExe(ctx context.Context, host string, role Role, memoryMax int64, x StagedExe) (string, error) {
267 return c.startUnitQuery(ctx, hostRole(host, role), memoryMax, &x)
268}
269
270// StartSidecarExe is StartExe for the legacy sidecar role: the serving
271// generation's staged build (zero-downtime spec §7.5).
272func (c *Client) StartSidecarExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (string, error) {
273 return c.StartExe(ctx, host, RoleSidecar, memoryMax, x)
274}
275
276// startUnitQuery is /start for a generation or the sidecar, optionally with
277// a staged executable. One place maps root's two distinct refusals: 403 is
278// ErrStagedExe (nothing was mounted), and the generation verbs' unproven
279// status is ErrGenUnproven.
280func (c *Client) startUnitQuery(ctx context.Context, q url.Values, memoryMax int64, x *StagedExe) (string, error) {
281 q.Set("memory_max", strconv.FormatInt(memoryMax, 10))
282 if x != nil {
283 setStagedExe(q, *x)
284 }
285 var out StartResponse
286 status, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/start", q, &out)
287 if err != nil {
288 switch {
289 case status == http.StatusForbidden && x != nil:
290 return "", fmt.Errorf("%w: %v", ErrStagedExe, err)
291 case status == statusGenUnproven && q.Has("gen"):
292 return "", fmt.Errorf("%w: %w", ErrGenUnproven, err)
293 }
294 return "", err
295 }
296 return out.InvocationID, nil
297}
298
299// StopGen stops generation n's unit only. kill kills it outright and
300// returns once its cgroup is empty.
301func (c *Client) StopGen(ctx context.Context, host string, n uint64, kill bool) error {
302 q := genValues(host, n)
303 if kill {
304 q.Set("kill", "1")
305 }
306 _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/stop", q, nil)
307 return err
308}
309
310func (c *Client) ResetGen(ctx context.Context, host string, n uint64) error {
311 _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/reset", genValues(host, n), nil)
312 return err
313}
314
315// Parent sets host's slice memory, the generation parent's backstop (spec
316// §7.3): one tier, or two summed during an overlap.
317func (c *Client) Parent(ctx context.Context, host string, memoryMax int64) error {
318 q := url.Values{"host": {host}, "memory_max": {strconv.FormatInt(memoryMax, 10)}}
319 _, _, err := c.do(ctx, DeadlineControl, http.MethodPost, "/parent", q, nil)
320 return err
321}
322
323// Sweep stops every instance unit on the box (design §6.7). A 502 carries
324// the units that were stopped and the ones that would not; both are
325// returned with the error.
326func (c *Client) Sweep(ctx context.Context) (SweepResponse, error) {
327 var out SweepResponse
328 status, _, err := c.do(ctx, DeadlineSweep, http.MethodPost, "/sweep", nil, &out)
329 if err != nil && status == http.StatusBadGateway {
330 return out, errors.Join(err, errors.New("some instance units would not stop"))
331 }
332 return out, err
333}
334
335// Ready is nil on 204 from a service of this agent's own build, a
336// *NotReadyError on 503, and a *StaleBuildError on a 204 whose BuildHeader
337// is missing or names another build (#256). The verdict is asked first:
338// a service that is both stale and not ready says why it is not ready.
339func (c *Client) Ready(ctx context.Context) error {
340 _, hdr, err := c.do(ctx, DeadlineReady, http.MethodGet, "/ready", nil, nil)
341 if err != nil {
342 return err
343 }
344 if got := hdr.Get(BuildHeader); c.Build != "" && got != c.Build {
345 return &StaleBuildError{Running: got, Want: c.Build}
346 }
347 return nil
348}
349