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