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 | "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 | |
| 24 | const ciRunsPerPage = 20 |
| 25 | |
| 26 | // ciRoutes mounts the pipeline pages. The caller applies the session |
| 27 | // middleware. |
| 28 | func (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. |
| 46 | func (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. |
| 65 | type 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. |
| 75 | func (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. |
| 105 | var 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. |
| 114 | var 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. |
| 124 | func 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 | |
| 138 | func (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 | |
| 201 | func (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 | |
| 230 | func (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 | |
| 258 | func (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 | |
| 278 | func (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 | |
| 294 | func (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. |
| 315 | var validSecretName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) |
| 316 | |
| 317 | func (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 | |
| 348 | func (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 | |
| 364 | func (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. |
| 400 | var 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. |
| 410 | func (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. |
| 435 | func 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 |