client.go
1package appview
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "strings"
12 "time"
13
14 "git.kilimanjaro.io/ci/internal/lex"
15 "git.kilimanjaro.io/ci/internal/runner"
16)
17
18// Runner is a client of a runner's API (internal/runner Handler).
19type Runner struct {
20 URL string // e.g. http://127.0.0.1:8476
21 Token string
22 HTTP *http.Client
23}
24
25func (c *Runner) do(ctx context.Context, method, path string, body any, out any) error {
26 var rd io.Reader
27 if body != nil {
28 b, err := json.Marshal(body)
29 if err != nil {
30 return err
31 }
32 rd = bytes.NewReader(b)
33 }
34 req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(c.URL, "/")+path, rd)
35 if err != nil {
36 return err
37 }
38 if c.Token != "" {
39 req.Header.Set("Authorization", "Bearer "+c.Token)
40 }
41 if body != nil {
42 req.Header.Set("Content-Type", "application/json")
43 }
44 hc := c.HTTP
45 if hc == nil {
46 hc = &http.Client{Timeout: 30 * time.Second}
47 }
48 resp, err := hc.Do(req)
49 if err != nil {
50 return err
51 }
52 defer resp.Body.Close()
53 if resp.StatusCode >= 300 {
54 var e struct {
55 Error string `json:"error"`
56 }
57 b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
58 if json.Unmarshal(b, &e) == nil && e.Error != "" {
59 return fmt.Errorf("%s", e.Error)
60 }
61 return fmt.Errorf("runner: %s: %s", resp.Status, strings.TrimSpace(string(b)))
62 }
63 if out == nil {
64 return nil
65 }
66 if w, ok := out.(io.Writer); ok {
67 _, err := io.Copy(w, resp.Body)
68 return err
69 }
70 return json.NewDecoder(resp.Body).Decode(out)
71}
72
73// Runs lists runs, newest first.
74func (c *Runner) Runs(ctx context.Context, repo string, limit int) ([]runner.RunView, error) {
75 q := url.Values{"limit": {fmt.Sprint(limit)}}
76 if repo != "" {
77 q.Set("repo", repo)
78 }
79 var out []runner.RunView
80 return out, c.do(ctx, "GET", "/api/runs?"+q.Encode(), nil, &out)
81}
82
83// Run returns one run.
84func (c *Runner) Run(ctx context.Context, id string) (runner.RunView, error) {
85 var out runner.RunView
86 return out, c.do(ctx, "GET", "/api/runs/"+url.PathEscape(id), nil, &out)
87}
88
89// Events returns a run's events after seq.
90func (c *Runner) Events(ctx context.Context, id string, after int) ([]lex.RunEvent, error) {
91 var buf bytes.Buffer
92 if err := c.do(ctx, "GET", fmt.Sprintf("/api/runs/%s/events?after=%d", url.PathEscape(id), after), nil, &buf); err != nil {
93 return nil, err
94 }
95 var out []lex.RunEvent
96 for _, line := range bytes.Split(buf.Bytes(), []byte("\n")) {
97 if len(bytes.TrimSpace(line)) == 0 {
98 continue
99 }
100 var ev lex.RunEvent
101 if err := json.Unmarshal(line, &ev); err != nil {
102 return nil, err
103 }
104 out = append(out, ev)
105 }
106 return out, nil
107}
108
109// Text copies a run's plain-text log to w.
110func (c *Runner) Text(ctx context.Context, id string, w io.Writer) error {
111 return c.do(ctx, "GET", "/api/runs/"+url.PathEscape(id)+"/log.txt", nil, w)
112}
113
114// File copies a run file (results/…, artifacts/…) to w.
115func (c *Runner) File(ctx context.Context, id, path string, w io.Writer) error {
116 return c.do(ctx, "GET", "/api/runs/"+url.PathEscape(id)+"/files/"+path, nil, w)
117}
118
119// Gate answers a run's gate: option is 1-based; dismiss ends the run.
120func (c *Runner) Gate(ctx context.Context, id string, option int, dismiss bool, actor string) error {
121 return c.do(ctx, "POST", "/api/runs/"+url.PathEscape(id)+"/gate",
122 map[string]any{"option": option, "dismiss": dismiss, "actor": actor}, nil)
123}
124
125// Action starts a manual action run and returns its id.
126func (c *Runner) Action(ctx context.Context, id, action, actor string) (string, error) {
127 var out struct {
128 Run string `json:"run"`
129 }
130 err := c.do(ctx, "POST", "/api/runs/"+url.PathEscape(id)+"/actions/"+url.PathEscape(action), map[string]any{"actor": actor}, &out)
131 return out.Run, err
132}
133
134// Cancel stops a run.
135func (c *Runner) Cancel(ctx context.Context, id string) error {
136 return c.do(ctx, "POST", "/api/runs/"+url.PathEscape(id)+"/cancel", nil, nil)
137}
138
139// Trigger starts a manual pipeline of repo (a DID) at rev (a commit or a
140// branch, resolved by the runner) for the named workflows (all if none).
141func (c *Runner) Trigger(ctx context.Context, repoDid, rev string, workflows []string) (string, error) {
142 in := lex.TriggerPipelineInput{Repo: repoDid, Workflows: workflows,
143 Trigger: &lex.ManualTrigger{LexiconTypeID: "sh.tangled.ci.trigger#manual", Sha: rev}}
144 if !isSha(rev) {
145 ref := "refs/heads/" + rev
146 in.Trigger.Ref = &ref
147 }
148 var out lex.TriggerPipelineOutput
149 return out.Pipeline, c.do(ctx, "POST", "/xrpc/sh.tangled.ci.triggerPipeline", in, &out)
150}
151
152func isSha(s string) bool {
153 if len(s) != 40 {
154 return false
155 }
156 for _, c := range s {
157 if !strings.ContainsRune("0123456789abcdef", c) {
158 return false
159 }
160 }
161 return true
162}
163
164// Forward sends a request through to the runner unchanged (method, path
165// with query, body) and copies the answer back: imports and lock leases from
166// local runs.
167func (c *Runner) Forward(w http.ResponseWriter, r *http.Request, path string) {
168 req, err := http.NewRequestWithContext(r.Context(), r.Method, strings.TrimSuffix(c.URL, "/")+path, r.Body)
169 if err != nil {
170 http.Error(w, err.Error(), http.StatusBadGateway)
171 return
172 }
173 if c.Token != "" {
174 req.Header.Set("Authorization", "Bearer "+c.Token)
175 }
176 if ct := r.Header.Get("Content-Type"); ct != "" {
177 req.Header.Set("Content-Type", ct)
178 }
179 hc := c.HTTP
180 if hc == nil {
181 hc = &http.Client{Timeout: 2 * time.Minute}
182 }
183 resp, err := hc.Do(req)
184 if err != nil {
185 http.Error(w, err.Error(), http.StatusBadGateway)
186 return
187 }
188 defer resp.Body.Close()
189 w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
190 w.WriteHeader(resp.StatusCode)
191 _, _ = io.Copy(w, resp.Body)
192}