remote.go
1package runner
2
3import (
4 "archive/tar"
5 "compress/gzip"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "sync"
16 "time"
17
18 "git.kilimanjaro.io/ci/internal/checkout"
19 "git.kilimanjaro.io/ci/internal/engine"
20 "git.kilimanjaro.io/ci/internal/lex"
21)
22
23// Remote locks: a local run (`ci run` on the workstation) takes a
24// concurrency group from the runner, as a lease on the same file lock the
25// runner's segments use, so a laptop deploy and a CI deploy never overlap.
26// The client renews the lease while it holds the group; a lease not renewed
27// within its TTL is released.
28
29// LeaseTTL is how long a lease lives without renewal.
30const LeaseTTL = 2 * time.Minute
31
32type lease struct {
33 holder string
34 release func()
35 expires time.Time
36}
37
38type leases struct {
39 mu sync.Mutex
40 m map[string]*lease
41}
42
43func (r *Runner) leaseTable() *leases {
44 r.mu.Lock()
45 defer r.mu.Unlock()
46 if r.leases == nil {
47 r.leases = &leases{m: map[string]*lease{}}
48 }
49 return r.leases
50}
51
52var groupName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
53
54// Lock takes or renews group for holder; it returns who holds it if not.
55func (r *Runner) Lock(ctx context.Context, group, holder string) (bool, string, error) {
56 if !groupName.MatchString(group) || holder == "" {
57 return false, "", errors.New("invalid group or holder")
58 }
59 t := r.leaseTable()
60 t.mu.Lock()
61 defer t.mu.Unlock()
62 if l, ok := t.m[group]; ok {
63 if l.holder == holder {
64 l.expires = time.Now().Add(LeaseTTL)
65 return true, holder, nil
66 }
67 if time.Now().Before(l.expires) {
68 return false, l.holder, nil
69 }
70 l.release()
71 delete(t.m, group)
72 }
73 release, by, err := engine.FileLocker{Dir: r.Cfg.LocksDir}.TryLock(ctx, group, holder)
74 if err != nil || release == nil {
75 return false, by, err
76 }
77 t.m[group] = &lease{holder: holder, release: release, expires: time.Now().Add(LeaseTTL)}
78 return true, holder, nil
79}
80
81// Unlock releases holder's lease on group.
82func (r *Runner) Unlock(group, holder string) {
83 t := r.leaseTable()
84 t.mu.Lock()
85 defer t.mu.Unlock()
86 if l, ok := t.m[group]; ok && l.holder == holder {
87 l.release()
88 delete(t.m, group)
89 }
90}
91
92func (r *Runner) serveLock(w http.ResponseWriter, req *http.Request) {
93 holder := req.URL.Query().Get("holder")
94 if req.Method == http.MethodDelete {
95 r.Unlock(req.PathValue("group"), holder)
96 writeJSON(w, 200, map[string]any{"released": true})
97 return
98 }
99 ok, by, err := r.Lock(req.Context(), req.PathValue("group"), holder)
100 if err != nil {
101 fail(w, 400, err)
102 return
103 }
104 code := 200
105 if !ok {
106 code = 409
107 }
108 writeJSON(w, code, map[string]any{"locked": ok, "holder": by, "ttl": LeaseTTL.Seconds()})
109}
110
111// Imported local runs: `ci run --report` uploads a snapshot of a local run
112// (state, events, results, artifacts; no checkouts) so it shows in the
113// appview's history, and its manual actions (rollback) can run here.
114
115var runID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{5,80}$`)
116
117// importable names what a snapshot may contain.
118func importable(name string) bool {
119 switch name {
120 case "state.json", "events.ndjson", "manifest.yml":
121 return true
122 }
123 return strings.HasPrefix(name, "state/") || strings.HasPrefix(name, "results/") || strings.HasPrefix(name, "artifacts/")
124}
125
126// Import stores a reported local run (a tar.gz of its run dir) as run id.
127func (r *Runner) Import(id string, body io.Reader) error {
128 if !runID.MatchString(id) {
129 return fmt.Errorf("invalid run id %q", id)
130 }
131 if x, err := r.GetRun(id); err == nil && x.Pipeline != "" {
132 return fmt.Errorf("run %s is not an imported run", id)
133 }
134 dir := filepath.Join(r.Cfg.RunsDir, id)
135 tmp := dir + ".import"
136 _ = os.RemoveAll(tmp)
137 if err := os.MkdirAll(tmp, 0o755); err != nil {
138 return err
139 }
140 defer func() { _ = os.RemoveAll(tmp) }()
141 zr, err := gzip.NewReader(io.LimitReader(body, 256<<20))
142 if err != nil {
143 return err
144 }
145 tr := tar.NewReader(zr)
146 for {
147 h, err := tr.Next()
148 if err == io.EOF {
149 break
150 }
151 if err != nil {
152 return err
153 }
154 name := filepath.Clean(h.Name)
155 if h.Typeflag != tar.TypeReg || !importable(name) || strings.Contains(name, "..") {
156 continue // checkouts, links and anything else stay behind
157 }
158 p := filepath.Join(tmp, name)
159 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
160 return err
161 }
162 f, err := os.Create(p)
163 if err != nil {
164 return err
165 }
166 _, err = io.Copy(f, io.LimitReader(tr, 64<<20))
167 f.Close()
168 if err != nil {
169 return err
170 }
171 }
172 st, err := engine.Load(engine.Dir(tmp))
173 if err != nil {
174 return fmt.Errorf("snapshot: %w", err)
175 }
176 if st.Meta.Run != id {
177 return fmt.Errorf("snapshot is of run %s, not %s", st.Meta.Run, id)
178 }
179 if st.Meta.Host != "local" {
180 return errors.New("only local runs can be imported")
181 }
182 _ = os.RemoveAll(dir)
183 if err := os.Rename(tmp, dir); err != nil {
184 return err
185 }
186 if err := os.MkdirAll(tmp, 0o755); err != nil { // for the deferred RemoveAll
187 return err
188 }
189 if _, err := r.GetRun(id); err == nil {
190 return r.updateRun(id, `status = ?, dir = ?`, st.Status, dir)
191 }
192 return r.insertRun(Run{ID: id, Workflow: st.Meta.Workflow, Repo: st.Meta.Repo, Dir: dir,
193 Status: st.Status, Created: st.Created, Updated: now()})
194}
195
196func (r *Runner) serveImport(w http.ResponseWriter, req *http.Request) {
197 if err := r.Import(req.PathValue("id"), req.Body); err != nil {
198 fail(w, 400, err)
199 return
200 }
201 writeJSON(w, 200, map[string]string{"run": req.PathValue("id")})
202}
203
204// relayout gives a manual run of an imported local run checkouts of its
205// own: the local paths mean nothing here, so each repo is checked out again
206// at the commit the local run recorded. A dirty working copy can't be
207// reproduced and is refused.
208func (r *Runner) relayout(ctx context.Context, meta *engine.Meta, m []lex.Repo, dir engine.Dir) error {
209 if meta.Dirty {
210 return errors.New("the local run used a working copy with changes; run its actions locally (ci action --run)")
211 }
212 if meta.Sha == "" {
213 return errors.New("the local run recorded no commit")
214 }
215 revs := map[string]string{}
216 for _, c := range meta.Checkouts {
217 if c.Rev == "linked" || c.Rev == "" {
218 if c.Repo != meta.Repo {
219 return fmt.Errorf("repo %s was a linked local directory (--repo); run its actions locally", c.Repo)
220 }
221 continue
222 }
223 revs[c.Repo] = c.Rev
224 }
225 mirror, err := r.src.Mirror(ctx, meta.Repo)
226 if err != nil {
227 return err
228 }
229 origin, _ := r.src.URL(meta.Repo)
230 extras := make([]lex.Repo, len(m))
231 for i, e := range m {
232 e.Rev = revs[e.Repo]
233 extras[i] = e
234 }
235 checked, err := checkout.Layout(ctx, r.src, dir.Path(), checkout.Main{Repo: meta.Repo, From: mirror, Sha: meta.Sha, Origin: origin}, extras, nil)
236 if err != nil {
237 return err
238 }
239 meta.Root = filepath.Join(dir.Path(), meta.Repo)
240 meta.Checkouts = nil
241 for _, c := range checked {
242 meta.Checkouts = append(meta.Checkouts, engine.Checkout{Repo: c.Repo, Path: c.Path, Rev: c.Rev, Env: c.Env})
243 }
244 meta.Host = "runner"
245 return nil
246}