Go · 7922 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package api is the shithubd-runner client for the S41c runner HTTP API.
4 package api
5
6 import (
7 "bytes"
8 "context"
9 "encoding/base64"
10 "encoding/json"
11 "fmt"
12 "io"
13 "net/http"
14 "net/url"
15 "strconv"
16 "strings"
17 "time"
18 )
19
20 type Config struct {
21 BaseURL string
22 RunnerToken string
23 HTTPClient *http.Client
24 }
25
26 type Client struct {
27 base *url.URL
28 runnerToken string
29 http *http.Client
30 }
31
32 func New(cfg Config) (*Client, error) {
33 base, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
34 if err != nil || base.Scheme == "" || base.Host == "" {
35 return nil, fmt.Errorf("runner api: invalid base URL %q", cfg.BaseURL)
36 }
37 if strings.TrimSpace(cfg.RunnerToken) == "" {
38 return nil, fmt.Errorf("runner api: runner token is required")
39 }
40 hc := cfg.HTTPClient
41 if hc == nil {
42 hc = http.DefaultClient
43 }
44 return &Client{base: base, runnerToken: strings.TrimSpace(cfg.RunnerToken), http: hc}, nil
45 }
46
47 type HeartbeatRequest struct {
48 Labels []string `json:"labels"`
49 Capacity int `json:"capacity"`
50 }
51
52 type Claim struct {
53 Token string `json:"token"`
54 ExpiresAt time.Time `json:"expires_at"`
55 Job Job `json:"job"`
56 }
57
58 type Job struct {
59 ID int64 `json:"id"`
60 RunID int64 `json:"run_id"`
61 RepoID int64 `json:"repo_id"`
62 RunIndex int64 `json:"run_index"`
63 WorkflowFile string `json:"workflow_file"`
64 WorkflowName string `json:"workflow_name"`
65 HeadSHA string `json:"head_sha"`
66 HeadRef string `json:"head_ref"`
67 Event string `json:"event"`
68 EventPayload map[string]any `json:"event_payload"`
69 JobKey string `json:"job_key"`
70 JobName string `json:"job_name"`
71 RunsOn string `json:"runs_on"`
72 Needs []string `json:"needs"`
73 If string `json:"if"`
74 TimeoutMinutes int32 `json:"timeout_minutes"`
75 Permissions json.RawMessage `json:"permissions"`
76 Env map[string]string `json:"env"`
77 Steps []Step `json:"steps"`
78 }
79
80 type Step struct {
81 ID int64 `json:"id"`
82 Index int32 `json:"index"`
83 StepID string `json:"step_id"`
84 Name string `json:"name"`
85 If string `json:"if"`
86 Run string `json:"run"`
87 Uses string `json:"uses"`
88 WorkingDirectory string `json:"working_directory"`
89 Env map[string]string `json:"env"`
90 With map[string]string `json:"with"`
91 ContinueOnError bool `json:"continue_on_error"`
92 }
93
94 type StatusRequest struct {
95 Status string `json:"status"`
96 Conclusion string `json:"conclusion,omitempty"`
97 StartedAt time.Time `json:"-"`
98 CompletedAt time.Time `json:"-"`
99 }
100
101 func (r StatusRequest) MarshalJSON() ([]byte, error) {
102 type wire struct {
103 Status string `json:"status"`
104 Conclusion string `json:"conclusion,omitempty"`
105 StartedAt string `json:"started_at,omitempty"`
106 CompletedAt string `json:"completed_at,omitempty"`
107 }
108 out := wire{Status: r.Status, Conclusion: r.Conclusion}
109 if !r.StartedAt.IsZero() {
110 out.StartedAt = r.StartedAt.UTC().Format(time.RFC3339Nano)
111 }
112 if !r.CompletedAt.IsZero() {
113 out.CompletedAt = r.CompletedAt.UTC().Format(time.RFC3339Nano)
114 }
115 return json.Marshal(out)
116 }
117
118 type StatusResponse struct {
119 Status string `json:"status"`
120 Conclusion *string `json:"conclusion"`
121 RunStatus string `json:"run_status,omitempty"`
122 RunConclusion string `json:"run_conclusion,omitempty"`
123 NextToken string `json:"next_token,omitempty"`
124 NextTokenExpiresAt time.Time `json:"next_token_expires_at,omitempty"`
125 }
126
127 type LogRequest struct {
128 Seq int32 `json:"seq"`
129 Chunk []byte `json:"-"`
130 StepID int64 `json:"step_id,omitempty"`
131 }
132
133 func (r LogRequest) MarshalJSON() ([]byte, error) {
134 type wire struct {
135 Seq int32 `json:"seq"`
136 Chunk string `json:"chunk"`
137 StepID int64 `json:"step_id,omitempty"`
138 }
139 return json.Marshal(wire{
140 Seq: r.Seq,
141 Chunk: base64.StdEncoding.EncodeToString(r.Chunk),
142 StepID: r.StepID,
143 })
144 }
145
146 type LogResponse struct {
147 Accepted bool `json:"accepted"`
148 NextToken string `json:"next_token"`
149 NextTokenExpiresAt time.Time `json:"next_token_expires_at"`
150 }
151
152 type StepStatusResponse struct {
153 Status string `json:"status"`
154 Conclusion *string `json:"conclusion"`
155 NextToken string `json:"next_token,omitempty"`
156 NextTokenExpiresAt time.Time `json:"next_token_expires_at,omitempty"`
157 }
158
159 type CancelCheckResponse struct {
160 Cancelled bool `json:"cancelled"`
161 NextToken string `json:"next_token"`
162 NextTokenExpiresAt time.Time `json:"next_token_expires_at"`
163 }
164
165 func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*Claim, error) {
166 var claim Claim
167 status, err := c.do(ctx, http.MethodPost, "/api/v1/runners/heartbeat", c.runnerToken, req, &claim)
168 if err != nil {
169 return nil, err
170 }
171 if status == http.StatusNoContent {
172 return nil, nil
173 }
174 return &claim, nil
175 }
176
177 func (c *Client) UpdateStatus(ctx context.Context, jobID int64, token string, req StatusRequest) (StatusResponse, error) {
178 var out StatusResponse
179 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "status"), token, req, &out)
180 return out, err
181 }
182
183 func (c *Client) UpdateStepStatus(ctx context.Context, jobID, stepID int64, token string, req StatusRequest) (StepStatusResponse, error) {
184 var out StepStatusResponse
185 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "steps/"+strconv.FormatInt(stepID, 10)+"/status"), token, req, &out)
186 return out, err
187 }
188
189 func (c *Client) AppendLog(ctx context.Context, jobID int64, token string, req LogRequest) (LogResponse, error) {
190 var out LogResponse
191 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "logs"), token, req, &out)
192 return out, err
193 }
194
195 func (c *Client) CancelCheck(ctx context.Context, jobID int64, token string) (CancelCheckResponse, error) {
196 var out CancelCheckResponse
197 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "cancel-check"), token, map[string]string{}, &out)
198 return out, err
199 }
200
201 func jobPath(jobID int64, suffix string) string {
202 return "/api/v1/jobs/" + strconv.FormatInt(jobID, 10) + "/" + suffix
203 }
204
205 func (c *Client) do(ctx context.Context, method, path, bearer string, body, out any) (int, error) {
206 var r io.Reader
207 if body != nil {
208 var buf bytes.Buffer
209 if err := json.NewEncoder(&buf).Encode(body); err != nil {
210 return 0, fmt.Errorf("runner api: encode %s %s: %w", method, path, err)
211 }
212 r = &buf
213 }
214 u := c.base.ResolveReference(&url.URL{Path: path})
215 req, err := http.NewRequestWithContext(ctx, method, u.String(), r)
216 if err != nil {
217 return 0, err
218 }
219 req.Header.Set("Accept", "application/json")
220 if body != nil {
221 req.Header.Set("Content-Type", "application/json")
222 }
223 if strings.TrimSpace(bearer) != "" {
224 req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
225 }
226 resp, err := c.http.Do(req)
227 if err != nil {
228 return 0, fmt.Errorf("runner api: %s %s: %w", method, path, err)
229 }
230 defer resp.Body.Close()
231 if resp.StatusCode == http.StatusNoContent {
232 return resp.StatusCode, nil
233 }
234 if resp.StatusCode < 200 || resp.StatusCode > 299 {
235 msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
236 return resp.StatusCode, fmt.Errorf("runner api: %s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(msg)))
237 }
238 if out == nil {
239 return resp.StatusCode, nil
240 }
241 if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
242 return resp.StatusCode, fmt.Errorf("runner api: decode %s %s: %w", method, path, err)
243 }
244 return resp.StatusCode, nil
245 }
246