Go · 8028 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 Secrets map[string]string `json:"secrets"`
77 MaskValues []string `json:"mask_values"`
78 Env map[string]string `json:"env"`
79 Steps []Step `json:"steps"`
80 }
81
82 type Step struct {
83 ID int64 `json:"id"`
84 Index int32 `json:"index"`
85 StepID string `json:"step_id"`
86 Name string `json:"name"`
87 If string `json:"if"`
88 Run string `json:"run"`
89 Uses string `json:"uses"`
90 WorkingDirectory string `json:"working_directory"`
91 Env map[string]string `json:"env"`
92 With map[string]string `json:"with"`
93 ContinueOnError bool `json:"continue_on_error"`
94 }
95
96 type StatusRequest struct {
97 Status string `json:"status"`
98 Conclusion string `json:"conclusion,omitempty"`
99 StartedAt time.Time `json:"-"`
100 CompletedAt time.Time `json:"-"`
101 }
102
103 func (r StatusRequest) MarshalJSON() ([]byte, error) {
104 type wire struct {
105 Status string `json:"status"`
106 Conclusion string `json:"conclusion,omitempty"`
107 StartedAt string `json:"started_at,omitempty"`
108 CompletedAt string `json:"completed_at,omitempty"`
109 }
110 out := wire{Status: r.Status, Conclusion: r.Conclusion}
111 if !r.StartedAt.IsZero() {
112 out.StartedAt = r.StartedAt.UTC().Format(time.RFC3339Nano)
113 }
114 if !r.CompletedAt.IsZero() {
115 out.CompletedAt = r.CompletedAt.UTC().Format(time.RFC3339Nano)
116 }
117 return json.Marshal(out)
118 }
119
120 type StatusResponse struct {
121 Status string `json:"status"`
122 Conclusion *string `json:"conclusion"`
123 RunStatus string `json:"run_status,omitempty"`
124 RunConclusion string `json:"run_conclusion,omitempty"`
125 NextToken string `json:"next_token,omitempty"`
126 NextTokenExpiresAt time.Time `json:"next_token_expires_at,omitempty"`
127 }
128
129 type LogRequest struct {
130 Seq int32 `json:"seq"`
131 Chunk []byte `json:"-"`
132 StepID int64 `json:"step_id,omitempty"`
133 }
134
135 func (r LogRequest) MarshalJSON() ([]byte, error) {
136 type wire struct {
137 Seq int32 `json:"seq"`
138 Chunk string `json:"chunk"`
139 StepID int64 `json:"step_id,omitempty"`
140 }
141 return json.Marshal(wire{
142 Seq: r.Seq,
143 Chunk: base64.StdEncoding.EncodeToString(r.Chunk),
144 StepID: r.StepID,
145 })
146 }
147
148 type LogResponse struct {
149 Accepted bool `json:"accepted"`
150 NextToken string `json:"next_token"`
151 NextTokenExpiresAt time.Time `json:"next_token_expires_at"`
152 }
153
154 type StepStatusResponse struct {
155 Status string `json:"status"`
156 Conclusion *string `json:"conclusion"`
157 NextToken string `json:"next_token,omitempty"`
158 NextTokenExpiresAt time.Time `json:"next_token_expires_at,omitempty"`
159 }
160
161 type CancelCheckResponse struct {
162 Cancelled bool `json:"cancelled"`
163 NextToken string `json:"next_token"`
164 NextTokenExpiresAt time.Time `json:"next_token_expires_at"`
165 }
166
167 func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*Claim, error) {
168 var claim Claim
169 status, err := c.do(ctx, http.MethodPost, "/api/v1/runners/heartbeat", c.runnerToken, req, &claim)
170 if err != nil {
171 return nil, err
172 }
173 if status == http.StatusNoContent {
174 return nil, nil
175 }
176 return &claim, nil
177 }
178
179 func (c *Client) UpdateStatus(ctx context.Context, jobID int64, token string, req StatusRequest) (StatusResponse, error) {
180 var out StatusResponse
181 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "status"), token, req, &out)
182 return out, err
183 }
184
185 func (c *Client) UpdateStepStatus(ctx context.Context, jobID, stepID int64, token string, req StatusRequest) (StepStatusResponse, error) {
186 var out StepStatusResponse
187 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "steps/"+strconv.FormatInt(stepID, 10)+"/status"), token, req, &out)
188 return out, err
189 }
190
191 func (c *Client) AppendLog(ctx context.Context, jobID int64, token string, req LogRequest) (LogResponse, error) {
192 var out LogResponse
193 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "logs"), token, req, &out)
194 return out, err
195 }
196
197 func (c *Client) CancelCheck(ctx context.Context, jobID int64, token string) (CancelCheckResponse, error) {
198 var out CancelCheckResponse
199 _, err := c.do(ctx, http.MethodPost, jobPath(jobID, "cancel-check"), token, map[string]string{}, &out)
200 return out, err
201 }
202
203 func jobPath(jobID int64, suffix string) string {
204 return "/api/v1/jobs/" + strconv.FormatInt(jobID, 10) + "/" + suffix
205 }
206
207 func (c *Client) do(ctx context.Context, method, path, bearer string, body, out any) (int, error) {
208 var r io.Reader
209 if body != nil {
210 var buf bytes.Buffer
211 if err := json.NewEncoder(&buf).Encode(body); err != nil {
212 return 0, fmt.Errorf("runner api: encode %s %s: %w", method, path, err)
213 }
214 r = &buf
215 }
216 u := c.base.ResolveReference(&url.URL{Path: path})
217 req, err := http.NewRequestWithContext(ctx, method, u.String(), r)
218 if err != nil {
219 return 0, err
220 }
221 req.Header.Set("Accept", "application/json")
222 if body != nil {
223 req.Header.Set("Content-Type", "application/json")
224 }
225 if strings.TrimSpace(bearer) != "" {
226 req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
227 }
228 resp, err := c.http.Do(req)
229 if err != nil {
230 return 0, fmt.Errorf("runner api: %s %s: %w", method, path, err)
231 }
232 defer resp.Body.Close()
233 if resp.StatusCode == http.StatusNoContent {
234 return resp.StatusCode, nil
235 }
236 if resp.StatusCode < 200 || resp.StatusCode > 299 {
237 msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
238 return resp.StatusCode, fmt.Errorf("runner api: %s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(msg)))
239 }
240 if out == nil {
241 return resp.StatusCode, nil
242 }
243 if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
244 return resp.StatusCode, fmt.Errorf("runner api: decode %s %s: %w", method, path, err)
245 }
246 return resp.StatusCode, nil
247 }
248