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