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