ci.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "errors"
6 "io"
7 "net/http"
8 "net/url"
9 "os"
10 "path/filepath"
11 "regexp"
12 "strconv"
13 "strings"
14
15 "github.com/go-chi/chi/v5"
16
17 "hearthforge/internal/ci"
18 "hearthforge/internal/db"
19 "hearthforge/internal/util"
20 "hearthforge/internal/web/views"
21)
22
23const ciRunsPerPage = 20
24
25// ciRoutes mounts the pipeline pages. The caller applies the session
26// middleware.
27func (s *Server) ciRoutes(r chi.Router) {
28 r.Get("/{repo}/ci/badge.svg", s.ciBadge)
29 r.Get("/{repo}/ci", s.ciHistory)
30 r.Get("/{repo}/ci/{runID}", s.ciRunDetail)
31 r.Get("/{repo}/ci/{runID}/artifacts/{artifactID}", s.ciArtifactDownload)
32
33 r.Group(func(r chi.Router) {
34 r.Use(s.requireAdmin)
35 r.Post("/{repo}/ci/run", s.ciTrigger)
36 r.Post("/{repo}/ci/{runID}/retry", s.ciRetry)
37 r.Post("/{repo}/ci/{runID}/cancel", s.ciCancel)
38 r.Post("/{repo}/ci/purge-cache", s.ciPurgeCache)
39 r.Post("/{repo}/settings/ci-secrets", s.ciSecretCreate)
40 r.Post("/{repo}/settings/ci-secrets/delete", s.ciSecretDelete)
41 })
42}
43
44// ciRunParam reads the run id from the URL and loads the row for this repo.
45func (s *Server) ciRunParam(w http.ResponseWriter, r *http.Request, repoID int64) (*db.CiRun, bool) {
46 runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
47 if err != nil {
48 http.Error(w, "Not found", http.StatusNotFound)
49 return nil, false
50 }
51 run, err := s.DB.CiRunInRepo(r.Context(), runID, repoID)
52 if err != nil {
53 http.Error(w, "Internal error", http.StatusInternalServerError)
54 return nil, false
55 }
56 if run == nil {
57 http.Error(w, "Not found", http.StatusNotFound)
58 return nil, false
59 }
60 return run, true
61}
62
63// ciHeadState is what the manual trigger found at the default branch.
64type ciHeadState struct {
65 Cfg *ci.Config
66 Branch string
67 SHA string
68 // Problem is "" when the config parsed, else one of the keys below.
69 Problem string
70}
71
72// ciHeadState reads .hearthforge-ci.toml at the tip of the default branch.
73func (s *Server) ciHeadState(ctx context.Context, repo *db.Repo) ciHeadState {
74 git := s.Git
75 branch := repo.DefaultBranch
76 if branch == "" {
77 branches, err := git.Branches(ctx, repo.Name)
78 if err == nil && len(branches) > 0 {
79 branch = branches[0]
80 }
81 }
82 if branch == "" {
83 return ciHeadState{Problem: "no_branches"}
84 }
85 sha, err := git.ResolveRef(ctx, repo.Name, branch)
86 if err != nil || sha == "" {
87 return ciHeadState{Problem: "no_commits"}
88 }
89 cfg, err := s.CI.ConfigAt(ctx, repo.Name, sha)
90 switch {
91 case errors.Is(err, ci.ErrNoConfig):
92 return ciHeadState{Branch: branch, SHA: sha, Problem: "no_toml"}
93 case err != nil:
94 return ciHeadState{Branch: branch, SHA: sha, Problem: "bad_toml"}
95 }
96 return ciHeadState{Cfg: cfg, Branch: branch, SHA: sha}
97}
98
99// ciHistoryReasons explains a disabled manual trigger button.
100var ciHistoryReasons = map[string]string{
101 "no_branches": "No branches — push a commit first",
102 "no_commits": "No commits yet",
103 "no_toml": "No .hearthforge-ci.toml found in repository",
104 "bad_toml": "Failed to parse .hearthforge-ci.toml",
105}
106
107// ciTriggerErrors are the messages the manual trigger POST answers with.
108var ciTriggerErrors = map[string]string{
109 "no_branches": "No branches",
110 "no_commits": "No commits",
111 "no_toml": "No .hearthforge-ci.toml found at HEAD. Add one to your repository to " +
112 "use CI pipelines.",
113 "bad_toml": "Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.",
114}
115
116// ciVariables lists the declared variables in file order.
117func ciVariables(cfg *ci.Config) []views.CiVariable {
118 if cfg == nil {
119 return nil
120 }
121 out := make([]views.CiVariable, 0, len(cfg.VariableOrder))
122 for _, name := range cfg.VariableOrder {
123 def := cfg.Variables[name]
124 out = append(out, views.CiVariable{
125 Name: name, Default: def.Default, Description: def.Description,
126 })
127 }
128 return out
129}
130
131func (s *Server) ciHistory(w http.ResponseWriter, r *http.Request) {
132 repo, ok := s.visibleRepo(w, r)
133 if !ok {
134 return
135 }
136 ctx := r.Context()
137 total, err := s.DB.CountCiRuns(ctx, repo.ID)
138 if err != nil {
139 http.Error(w, "Internal error", http.StatusInternalServerError)
140 return
141 }
142 page := util.Paginate(util.ParsePage(r.URL.Query().Get("page")), total, ciRunsPerPage)
143 runs, err := s.DB.ListCiRuns(ctx, repo.ID, ciRunsPerPage, page.Offset)
144 if err != nil {
145 http.Error(w, "Internal error", http.StatusInternalServerError)
146 return
147 }
148 ids := make([]int64, 0, len(runs))
149 for _, run := range runs {
150 ids = append(ids, run.ID)
151 }
152 counts, err := s.DB.CiArtifactCounts(ctx, ids)
153 if err != nil {
154 http.Error(w, "Internal error", http.StatusInternalServerError)
155 return
156 }
157 summaries := make([]views.CiRunSummary, 0, len(runs))
158 for _, run := range runs {
159 sum := views.CiRunSummary{Run: run, ArtifactCount: counts[run.ID]}
160 if run.Status == "queued" && s.CI != nil {
161 sum.QueuePosition = s.CI.QueuePosition(run.ID)
162 }
163 summaries = append(summaries, sum)
164 }
165
166 // Only an admin sees the manual trigger, so only they need the config.
167 var reason string
168 var variables []views.CiVariable
169 if u := User(r); u != nil && u.IsAdmin {
170 state := s.ciHeadState(ctx, repo)
171 reason = ciHistoryReasons[state.Problem]
172 variables = ciVariables(state.Cfg)
173 }
174
175 q := r.URL.Query()
176 views.Render(w, http.StatusOK, views.CiHistory(s.Cfg, User(r), repo, summaries,
177 views.PageInfo{
178 Page: page.Page, TotalPages: page.TotalPages,
179 URLTemplate: "/" + repo.Name + "/ci?page={page}",
180 },
181 reason, variables, q.Get("success"), q.Get("error")))
182}
183
184func (s *Server) ciRunDetail(w http.ResponseWriter, r *http.Request) {
185 repo, ok := s.visibleRepo(w, r)
186 if !ok {
187 return
188 }
189 run, ok := s.ciRunParam(w, r, repo.ID)
190 if !ok {
191 return
192 }
193 ctx := r.Context()
194 steps, err := s.DB.ListCiSteps(ctx, run.ID)
195 if err != nil {
196 http.Error(w, "Internal error", http.StatusInternalServerError)
197 return
198 }
199 artifacts, err := s.DB.ListCiArtifacts(ctx, run.ID)
200 if err != nil {
201 http.Error(w, "Internal error", http.StatusInternalServerError)
202 return
203 }
204 queuePosition := 0
205 if run.Status == "queued" && s.CI != nil {
206 queuePosition = s.CI.QueuePosition(run.ID)
207 }
208 autoRefresh := r.URL.Query().Get("refresh") != "off"
209 views.Render(w, http.StatusOK, views.CiRunDetail(s.Cfg, User(r), repo, run, steps,
210 artifacts, autoRefresh, queuePosition))
211}
212
213func (s *Server) ciTrigger(w http.ResponseWriter, r *http.Request) {
214 repo, ok := s.visibleRepo(w, r)
215 if !ok {
216 return
217 }
218 if err := r.ParseForm(); err != nil {
219 http.Error(w, "Bad request", http.StatusBadRequest)
220 return
221 }
222 state := s.ciHeadState(r.Context(), repo)
223 if state.Problem != "" {
224 http.Error(w, ciTriggerErrors[state.Problem], http.StatusBadRequest)
225 return
226 }
227 runID, err := s.CI.TriggerRun(r.Context(), repo.Name, ci.TriggerOpts{
228 TriggerSource: "manual",
229 CommitSha: state.SHA,
230 CommitBranch: state.Branch,
231 TriggeredBy: User(r).ID,
232 VariableOverrides: ci.VariableOverrides(state.Cfg, r.Form),
233 })
234 if err != nil {
235 http.Error(w, "Internal error", http.StatusInternalServerError)
236 return
237 }
238 http.Redirect(w, r, "/"+repo.Name+"/ci/"+strconv.FormatInt(runID, 10), http.StatusFound)
239}
240
241func (s *Server) ciRetry(w http.ResponseWriter, r *http.Request) {
242 repo, ok := s.visibleRepo(w, r)
243 if !ok {
244 return
245 }
246 run, ok := s.ciRunParam(w, r, repo.ID)
247 if !ok {
248 return
249 }
250 if err := s.CI.RetryRun(r.Context(), run.ID, User(r).ID); err != nil {
251 if errors.Is(err, ci.ErrRunNotFinished) {
252 http.Error(w, "This run is not finished yet.", http.StatusConflict)
253 return
254 }
255 http.Error(w, "Internal error", http.StatusInternalServerError)
256 return
257 }
258 http.Redirect(w, r, "/"+repo.Name+"/ci/"+strconv.FormatInt(run.ID, 10), http.StatusFound)
259}
260
261func (s *Server) ciCancel(w http.ResponseWriter, r *http.Request) {
262 repo, ok := s.visibleRepo(w, r)
263 if !ok {
264 return
265 }
266 run, ok := s.ciRunParam(w, r, repo.ID)
267 if !ok {
268 return
269 }
270 if err := s.CI.CancelRun(r.Context(), run.ID); err != nil {
271 http.Error(w, "Internal error", http.StatusInternalServerError)
272 return
273 }
274 http.Redirect(w, r, "/"+repo.Name+"/ci/"+strconv.FormatInt(run.ID, 10), http.StatusFound)
275}
276
277func (s *Server) ciPurgeCache(w http.ResponseWriter, r *http.Request) {
278 repo, ok := s.visibleRepo(w, r)
279 if !ok {
280 return
281 }
282 query := url.Values{}
283 removed, err := s.CI.PurgeRepoCaches(r.Context(), repo.Name)
284 switch {
285 case err != nil:
286 query.Set("error", "Failed to purge caches. Is Docker reachable?")
287 case removed == 0:
288 query.Set("success", "No cache volumes to purge.")
289 case removed == 1:
290 query.Set("success", "Purged 1 cache volume.")
291 default:
292 query.Set("success", "Purged "+strconv.Itoa(removed)+" cache volumes.")
293 }
294 http.Redirect(w, r, "/"+repo.Name+"/ci?"+encodeQuery(query), http.StatusFound)
295}
296
297// validSecretName is the identifier rule for CI secret names.
298var validSecretName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
299
300func (s *Server) ciSecretCreate(w http.ResponseWriter, r *http.Request) {
301 repo, ok := s.visibleRepo(w, r)
302 if !ok {
303 return
304 }
305 name := strings.TrimSpace(r.FormValue("name"))
306 value := r.FormValue("value")
307 description := strings.TrimSpace(r.FormValue("description"))
308
309 settings := "/" + repo.Name + "/settings"
310 if !validSecretName.MatchString(name) {
311 http.Redirect(w, r, settings+"?error="+
312 queryEscape("Secret name must be a valid identifier."), http.StatusFound)
313 return
314 }
315 if value == "" {
316 http.Redirect(w, r, settings+"?error="+
317 queryEscape("Secret value cannot be empty."), http.StatusFound)
318 return
319 }
320 var desc *string
321 if description != "" {
322 desc = &description
323 }
324 if err := s.DB.UpsertCiSecret(r.Context(), repo.ID, name, value, desc); err != nil {
325 http.Error(w, "Internal error", http.StatusInternalServerError)
326 return
327 }
328 http.Redirect(w, r, settings+"?success="+queryEscape("Secret saved."), http.StatusFound)
329}
330
331func (s *Server) ciSecretDelete(w http.ResponseWriter, r *http.Request) {
332 repo, ok := s.visibleRepo(w, r)
333 if !ok {
334 return
335 }
336 id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
337 if err == nil {
338 if err := s.DB.DeleteCiSecret(r.Context(), id, repo.ID); err != nil {
339 http.Error(w, "Internal error", http.StatusInternalServerError)
340 return
341 }
342 }
343 http.Redirect(w, r, "/"+repo.Name+"/settings?success="+
344 queryEscape("Secret deleted."), http.StatusFound)
345}
346
347func (s *Server) ciArtifactDownload(w http.ResponseWriter, r *http.Request) {
348 repo, ok := s.visibleRepo(w, r)
349 if !ok {
350 return
351 }
352 runID, err1 := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
353 artifactID, err2 := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
354 if err1 != nil || err2 != nil {
355 http.Error(w, "Not found", http.StatusNotFound)
356 return
357 }
358 artifact, err := s.DB.CiArtifactInRun(r.Context(), artifactID, runID, repo.ID)
359 if err != nil {
360 http.Error(w, "Internal error", http.StatusInternalServerError)
361 return
362 }
363 // Only a file listed for this run is served, and its name must be a plain
364 // file name, so a stored path cannot escape the run directory.
365 if artifact == nil || artifact.Filename != filepath.Base(artifact.Filename) {
366 http.Error(w, "Not found", http.StatusNotFound)
367 return
368 }
369 path := filepath.Join(s.Cfg.CIArtifactsDir(), strconv.FormatInt(runID, 10), artifact.Filename)
370 f, err := os.Open(path)
371 if err != nil {
372 http.Error(w, "File not found", http.StatusNotFound)
373 return
374 }
375 defer f.Close()
376 w.Header().Set("Content-Disposition", util.ContentDisposition("attachment", artifact.Filename))
377 w.Header().Set("Content-Type", "application/octet-stream")
378 w.Header().Set("Content-Length", strconv.FormatInt(artifact.Size, 10))
379 io.Copy(w, f)
380}
381
382// ciBadgeColors maps a run status to its badge colour.
383var ciBadgeColors = map[string]string{
384 "success": "#4c1",
385 "warning": "#dfb317",
386 "failure": "#e05d44",
387 "running": "#007ec6",
388 "pending": "#9f9f9f",
389 "cancelled": "#9f9f9f",
390}
391
392// ciBadge serves the README status badge. Private repositories have no badge.
393func (s *Server) ciBadge(w http.ResponseWriter, r *http.Request) {
394 repo, err := s.DB.RepoByName(r.Context(), chi.URLParam(r, "repo"))
395 if err != nil {
396 http.Error(w, "Internal error", http.StatusInternalServerError)
397 return
398 }
399 if repo == nil || repo.IsPrivate {
400 http.Error(w, "Not found", http.StatusNotFound)
401 return
402 }
403 status, err := s.DB.LatestCiRunStatus(r.Context(), repo.ID)
404 if err != nil {
405 http.Error(w, "Internal error", http.StatusInternalServerError)
406 return
407 }
408 if status == "" {
409 status = "no builds"
410 }
411 w.Header().Set("Content-Type", "image/svg+xml")
412 w.Header().Set("Cache-Control", "no-cache")
413 w.Write([]byte(ciBadgeSVG(status)))
414}
415
416// ciBadgeSVG draws the two-part badge. Widths are estimated from the text
417// length.
418func ciBadgeSVG(status string) string {
419 color := ciBadgeColors[status]
420 if color == "" {
421 color = "#9f9f9f"
422 }
423 const label = "pipeline"
424 labelWidth := len(label)*6 + 10
425 valueWidth := len(status)*6 + 10
426 totalWidth := labelWidth + valueWidth
427 n := func(i int) string { return strconv.Itoa(i) }
428 half := func(i int) string { return strconv.FormatFloat(float64(i)/2, 'g', -1, 64) }
429 return `<svg xmlns="http://www.w3.org/2000/svg" width="` + n(totalWidth) + `" height="20">
430 <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
431 <clipPath id="r"><rect width="` + n(totalWidth) + `" height="20" rx="3"/></clipPath>
432 <g clip-path="url(#r)">
433 <rect width="` + n(labelWidth) + `" height="20" fill="#555"/>
434 <rect x="` + n(labelWidth) + `" width="` + n(valueWidth) + `" height="20" fill="` + color + `"/>
435 <rect width="` + n(totalWidth) + `" height="20" fill="url(#s)"/>
436 </g>
437 <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
438 <text x="` + half(labelWidth) + `" y="15" fill="#010101" fill-opacity=".3">` + label + `</text>
439 <text x="` + half(labelWidth) + `" y="14">` + label + `</text>
440 <text x="` + strconv.FormatFloat(float64(labelWidth)+float64(valueWidth)/2, 'g', -1, 64) + `" y="15" fill="#010101" fill-opacity=".3">` + status + `</text>
441 <text x="` + strconv.FormatFloat(float64(labelWidth)+float64(valueWidth)/2, 'g', -1, 64) + `" y="14">` + status + `</text>
442 </g>
443</svg>`
444}
445