ci.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 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 | |
| 23 | const ciRunsPerPage = 20 |
| 24 | |
| 25 | // ciRoutes mounts the pipeline pages. The caller applies the session |
| 26 | // middleware. |
| 27 | func (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. |
| 45 | func (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. |
| 64 | type 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. |
| 73 | func (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. |
| 100 | var 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. |
| 108 | var 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. |
| 117 | func 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 | |
| 131 | func (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 | |
| 184 | func (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 | |
| 213 | func (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 | |
| 241 | func (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 | |
| 261 | func (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 | |
| 277 | func (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. |
| 298 | var validSecretName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) |
| 299 | |
| 300 | func (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 | |
| 331 | func (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 | |
| 347 | func (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. |
| 383 | var 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. |
| 393 | func (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. |
| 418 | func 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 |