code.gitea.io/gitea@v1.21.7/routers/web/repo/actions/view.go (about) 1 // Copyright 2022 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package actions 5 6 import ( 7 "archive/zip" 8 "compress/gzip" 9 "context" 10 "errors" 11 "fmt" 12 "io" 13 "net/http" 14 "net/url" 15 "strings" 16 "time" 17 18 actions_model "code.gitea.io/gitea/models/actions" 19 "code.gitea.io/gitea/models/db" 20 repo_model "code.gitea.io/gitea/models/repo" 21 "code.gitea.io/gitea/models/unit" 22 "code.gitea.io/gitea/modules/actions" 23 "code.gitea.io/gitea/modules/base" 24 context_module "code.gitea.io/gitea/modules/context" 25 "code.gitea.io/gitea/modules/storage" 26 "code.gitea.io/gitea/modules/timeutil" 27 "code.gitea.io/gitea/modules/util" 28 "code.gitea.io/gitea/modules/web" 29 actions_service "code.gitea.io/gitea/services/actions" 30 31 "xorm.io/builder" 32 ) 33 34 func View(ctx *context_module.Context) { 35 ctx.Data["PageIsActions"] = true 36 runIndex := ctx.ParamsInt64("run") 37 jobIndex := ctx.ParamsInt64("job") 38 ctx.Data["RunIndex"] = runIndex 39 ctx.Data["JobIndex"] = jobIndex 40 ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" 41 42 if getRunJobs(ctx, runIndex, jobIndex); ctx.Written() { 43 return 44 } 45 46 ctx.HTML(http.StatusOK, tplViewActions) 47 } 48 49 type ViewRequest struct { 50 LogCursors []struct { 51 Step int `json:"step"` 52 Cursor int64 `json:"cursor"` 53 Expanded bool `json:"expanded"` 54 } `json:"logCursors"` 55 } 56 57 type ViewResponse struct { 58 State struct { 59 Run struct { 60 Link string `json:"link"` 61 Title string `json:"title"` 62 Status string `json:"status"` 63 CanCancel bool `json:"canCancel"` 64 CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve 65 CanRerun bool `json:"canRerun"` 66 Done bool `json:"done"` 67 Jobs []*ViewJob `json:"jobs"` 68 Commit ViewCommit `json:"commit"` 69 } `json:"run"` 70 CurrentJob struct { 71 Title string `json:"title"` 72 Detail string `json:"detail"` 73 Steps []*ViewJobStep `json:"steps"` 74 } `json:"currentJob"` 75 } `json:"state"` 76 Logs struct { 77 StepsLog []*ViewStepLog `json:"stepsLog"` 78 } `json:"logs"` 79 } 80 81 type ViewJob struct { 82 ID int64 `json:"id"` 83 Name string `json:"name"` 84 Status string `json:"status"` 85 CanRerun bool `json:"canRerun"` 86 Duration string `json:"duration"` 87 } 88 89 type ViewCommit struct { 90 LocaleCommit string `json:"localeCommit"` 91 LocalePushedBy string `json:"localePushedBy"` 92 ShortSha string `json:"shortSHA"` 93 Link string `json:"link"` 94 Pusher ViewUser `json:"pusher"` 95 Branch ViewBranch `json:"branch"` 96 } 97 98 type ViewUser struct { 99 DisplayName string `json:"displayName"` 100 Link string `json:"link"` 101 } 102 103 type ViewBranch struct { 104 Name string `json:"name"` 105 Link string `json:"link"` 106 } 107 108 type ViewJobStep struct { 109 Summary string `json:"summary"` 110 Duration string `json:"duration"` 111 Status string `json:"status"` 112 } 113 114 type ViewStepLog struct { 115 Step int `json:"step"` 116 Cursor int64 `json:"cursor"` 117 Lines []*ViewStepLogLine `json:"lines"` 118 Started int64 `json:"started"` 119 } 120 121 type ViewStepLogLine struct { 122 Index int64 `json:"index"` 123 Message string `json:"message"` 124 Timestamp float64 `json:"timestamp"` 125 } 126 127 func ViewPost(ctx *context_module.Context) { 128 req := web.GetForm(ctx).(*ViewRequest) 129 runIndex := ctx.ParamsInt64("run") 130 jobIndex := ctx.ParamsInt64("job") 131 132 current, jobs := getRunJobs(ctx, runIndex, jobIndex) 133 if ctx.Written() { 134 return 135 } 136 run := current.Run 137 if err := run.LoadAttributes(ctx); err != nil { 138 ctx.Error(http.StatusInternalServerError, err.Error()) 139 return 140 } 141 142 resp := &ViewResponse{} 143 144 resp.State.Run.Title = run.Title 145 resp.State.Run.Link = run.Link() 146 resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) 147 resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) 148 resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) 149 resp.State.Run.Done = run.Status.IsDone() 150 resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json 151 resp.State.Run.Status = run.Status.String() 152 for _, v := range jobs { 153 resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ 154 ID: v.ID, 155 Name: v.Name, 156 Status: v.Status.String(), 157 CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions), 158 Duration: v.Duration().String(), 159 }) 160 } 161 162 pusher := ViewUser{ 163 DisplayName: run.TriggerUser.GetDisplayName(), 164 Link: run.TriggerUser.HomeLink(), 165 } 166 branch := ViewBranch{ 167 Name: run.PrettyRef(), 168 Link: run.RefLink(), 169 } 170 resp.State.Run.Commit = ViewCommit{ 171 LocaleCommit: ctx.Tr("actions.runs.commit"), 172 LocalePushedBy: ctx.Tr("actions.runs.pushed_by"), 173 ShortSha: base.ShortSha(run.CommitSHA), 174 Link: fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA), 175 Pusher: pusher, 176 Branch: branch, 177 } 178 179 var task *actions_model.ActionTask 180 if current.TaskID > 0 { 181 var err error 182 task, err = actions_model.GetTaskByID(ctx, current.TaskID) 183 if err != nil { 184 ctx.Error(http.StatusInternalServerError, err.Error()) 185 return 186 } 187 task.Job = current 188 if err := task.LoadAttributes(ctx); err != nil { 189 ctx.Error(http.StatusInternalServerError, err.Error()) 190 return 191 } 192 } 193 194 resp.State.CurrentJob.Title = current.Name 195 resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) 196 if run.NeedApproval { 197 resp.State.CurrentJob.Detail = ctx.Locale.Tr("actions.need_approval_desc") 198 } 199 resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json 200 resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json 201 if task != nil { 202 steps := actions.FullSteps(task) 203 204 for _, v := range steps { 205 resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{ 206 Summary: v.Name, 207 Duration: v.Duration().String(), 208 Status: v.Status.String(), 209 }) 210 } 211 212 for _, cursor := range req.LogCursors { 213 if !cursor.Expanded { 214 continue 215 } 216 217 step := steps[cursor.Step] 218 219 logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json 220 221 index := step.LogIndex + cursor.Cursor 222 validCursor := cursor.Cursor >= 0 && 223 // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready. 224 // So return the same cursor and empty lines to let the frontend retry. 225 cursor.Cursor < step.LogLength && 226 // !(index < task.LogIndexes[index]) when task data is older than step data. 227 // It can be fixed by making sure write/read tasks and steps in the same transaction, 228 // but it's easier to just treat it as fetching the next line before it's ready. 229 index < int64(len(task.LogIndexes)) 230 231 if validCursor { 232 length := step.LogLength - cursor.Cursor 233 offset := task.LogIndexes[index] 234 var err error 235 logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) 236 if err != nil { 237 ctx.Error(http.StatusInternalServerError, err.Error()) 238 return 239 } 240 241 for i, row := range logRows { 242 logLines = append(logLines, &ViewStepLogLine{ 243 Index: cursor.Cursor + int64(i) + 1, // start at 1 244 Message: row.Content, 245 Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), 246 }) 247 } 248 } 249 250 resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ 251 Step: cursor.Step, 252 Cursor: cursor.Cursor + int64(len(logLines)), 253 Lines: logLines, 254 Started: int64(step.Started), 255 }) 256 } 257 } 258 259 ctx.JSON(http.StatusOK, resp) 260 } 261 262 // Rerun will rerun jobs in the given run 263 // jobIndex = 0 means rerun all jobs 264 func Rerun(ctx *context_module.Context) { 265 runIndex := ctx.ParamsInt64("run") 266 jobIndex := ctx.ParamsInt64("job") 267 268 run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) 269 if err != nil { 270 ctx.Error(http.StatusInternalServerError, err.Error()) 271 return 272 } 273 274 // can not rerun job when workflow is disabled 275 cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) 276 cfg := cfgUnit.ActionsConfig() 277 if cfg.IsWorkflowDisabled(run.WorkflowID) { 278 ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled")) 279 return 280 } 281 282 job, jobs := getRunJobs(ctx, runIndex, jobIndex) 283 if ctx.Written() { 284 return 285 } 286 287 if jobIndex != 0 { 288 jobs = []*actions_model.ActionRunJob{job} 289 } 290 291 for _, j := range jobs { 292 if err := rerunJob(ctx, j); err != nil { 293 ctx.Error(http.StatusInternalServerError, err.Error()) 294 return 295 } 296 } 297 298 ctx.JSON(http.StatusOK, struct{}{}) 299 } 300 301 func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob) error { 302 status := job.Status 303 if !status.IsDone() { 304 return nil 305 } 306 307 job.TaskID = 0 308 job.Status = actions_model.StatusWaiting 309 job.Started = 0 310 job.Stopped = 0 311 312 if err := db.WithTx(ctx, func(ctx context.Context) error { 313 _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "task_id", "status", "started", "stopped") 314 return err 315 }); err != nil { 316 return err 317 } 318 319 actions_service.CreateCommitStatus(ctx, job) 320 return nil 321 } 322 323 func Logs(ctx *context_module.Context) { 324 runIndex := ctx.ParamsInt64("run") 325 jobIndex := ctx.ParamsInt64("job") 326 327 job, _ := getRunJobs(ctx, runIndex, jobIndex) 328 if ctx.Written() { 329 return 330 } 331 if job.TaskID == 0 { 332 ctx.Error(http.StatusNotFound, "job is not started") 333 return 334 } 335 336 err := job.LoadRun(ctx) 337 if err != nil { 338 ctx.Error(http.StatusInternalServerError, err.Error()) 339 return 340 } 341 342 task, err := actions_model.GetTaskByID(ctx, job.TaskID) 343 if err != nil { 344 ctx.Error(http.StatusInternalServerError, err.Error()) 345 return 346 } 347 if task.LogExpired { 348 ctx.Error(http.StatusNotFound, "logs have been cleaned up") 349 return 350 } 351 352 reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) 353 if err != nil { 354 ctx.Error(http.StatusInternalServerError, err.Error()) 355 return 356 } 357 defer reader.Close() 358 359 workflowName := job.Run.WorkflowID 360 if p := strings.Index(workflowName, "."); p > 0 { 361 workflowName = workflowName[0:p] 362 } 363 ctx.ServeContent(reader, &context_module.ServeHeaderOptions{ 364 Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, job.Name, task.ID), 365 ContentLength: &task.LogSize, 366 ContentType: "text/plain", 367 ContentTypeCharset: "utf-8", 368 Disposition: "attachment", 369 }) 370 } 371 372 func Cancel(ctx *context_module.Context) { 373 runIndex := ctx.ParamsInt64("run") 374 375 _, jobs := getRunJobs(ctx, runIndex, -1) 376 if ctx.Written() { 377 return 378 } 379 380 if err := db.WithTx(ctx, func(ctx context.Context) error { 381 for _, job := range jobs { 382 status := job.Status 383 if status.IsDone() { 384 continue 385 } 386 if job.TaskID == 0 { 387 job.Status = actions_model.StatusCancelled 388 job.Stopped = timeutil.TimeStampNow() 389 n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") 390 if err != nil { 391 return err 392 } 393 if n == 0 { 394 return fmt.Errorf("job has changed, try again") 395 } 396 continue 397 } 398 if err := actions_model.StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil { 399 return err 400 } 401 } 402 return nil 403 }); err != nil { 404 ctx.Error(http.StatusInternalServerError, err.Error()) 405 return 406 } 407 408 actions_service.CreateCommitStatus(ctx, jobs...) 409 410 ctx.JSON(http.StatusOK, struct{}{}) 411 } 412 413 func Approve(ctx *context_module.Context) { 414 runIndex := ctx.ParamsInt64("run") 415 416 current, jobs := getRunJobs(ctx, runIndex, -1) 417 if ctx.Written() { 418 return 419 } 420 run := current.Run 421 doer := ctx.Doer 422 423 if err := db.WithTx(ctx, func(ctx context.Context) error { 424 run.NeedApproval = false 425 run.ApprovedBy = doer.ID 426 if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { 427 return err 428 } 429 for _, job := range jobs { 430 if len(job.Needs) == 0 && job.Status.IsBlocked() { 431 job.Status = actions_model.StatusWaiting 432 _, err := actions_model.UpdateRunJob(ctx, job, nil, "status") 433 if err != nil { 434 return err 435 } 436 } 437 } 438 return nil 439 }); err != nil { 440 ctx.Error(http.StatusInternalServerError, err.Error()) 441 return 442 } 443 444 actions_service.CreateCommitStatus(ctx, jobs...) 445 446 ctx.JSON(http.StatusOK, struct{}{}) 447 } 448 449 // getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs. 450 // Any error will be written to the ctx. 451 // It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0. 452 func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) { 453 run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) 454 if err != nil { 455 if errors.Is(err, util.ErrNotExist) { 456 ctx.Error(http.StatusNotFound, err.Error()) 457 return nil, nil 458 } 459 ctx.Error(http.StatusInternalServerError, err.Error()) 460 return nil, nil 461 } 462 run.Repo = ctx.Repo.Repository 463 464 jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) 465 if err != nil { 466 ctx.Error(http.StatusInternalServerError, err.Error()) 467 return nil, nil 468 } 469 if len(jobs) == 0 { 470 ctx.Error(http.StatusNotFound, err.Error()) 471 return nil, nil 472 } 473 474 for _, v := range jobs { 475 v.Run = run 476 } 477 478 if jobIndex >= 0 && jobIndex < int64(len(jobs)) { 479 return jobs[jobIndex], jobs 480 } 481 return jobs[0], jobs 482 } 483 484 type ArtifactsViewResponse struct { 485 Artifacts []*ArtifactsViewItem `json:"artifacts"` 486 } 487 488 type ArtifactsViewItem struct { 489 Name string `json:"name"` 490 Size int64 `json:"size"` 491 Status string `json:"status"` 492 } 493 494 func ArtifactsView(ctx *context_module.Context) { 495 runIndex := ctx.ParamsInt64("run") 496 run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) 497 if err != nil { 498 if errors.Is(err, util.ErrNotExist) { 499 ctx.Error(http.StatusNotFound, err.Error()) 500 return 501 } 502 ctx.Error(http.StatusInternalServerError, err.Error()) 503 return 504 } 505 artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID) 506 if err != nil { 507 ctx.Error(http.StatusInternalServerError, err.Error()) 508 return 509 } 510 artifactsResponse := ArtifactsViewResponse{ 511 Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)), 512 } 513 for _, art := range artifacts { 514 status := "completed" 515 if art.Status == int64(actions_model.ArtifactStatusExpired) { 516 status = "expired" 517 } 518 artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{ 519 Name: art.ArtifactName, 520 Size: art.FileSize, 521 Status: status, 522 }) 523 } 524 ctx.JSON(http.StatusOK, artifactsResponse) 525 } 526 527 func ArtifactsDownloadView(ctx *context_module.Context) { 528 runIndex := ctx.ParamsInt64("run") 529 artifactName := ctx.Params("artifact_name") 530 531 run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) 532 if err != nil { 533 if errors.Is(err, util.ErrNotExist) { 534 ctx.Error(http.StatusNotFound, err.Error()) 535 return 536 } 537 ctx.Error(http.StatusInternalServerError, err.Error()) 538 return 539 } 540 541 artifacts, err := actions_model.ListArtifactsByRunIDAndName(ctx, run.ID, artifactName) 542 if err != nil { 543 ctx.Error(http.StatusInternalServerError, err.Error()) 544 return 545 } 546 if len(artifacts) == 0 { 547 ctx.Error(http.StatusNotFound, "artifact not found") 548 return 549 } 550 551 ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) 552 553 writer := zip.NewWriter(ctx.Resp) 554 defer writer.Close() 555 for _, art := range artifacts { 556 557 f, err := storage.ActionsArtifacts.Open(art.StoragePath) 558 if err != nil { 559 ctx.Error(http.StatusInternalServerError, err.Error()) 560 return 561 } 562 563 var r io.ReadCloser 564 if art.ContentEncoding == "gzip" { 565 r, err = gzip.NewReader(f) 566 if err != nil { 567 ctx.Error(http.StatusInternalServerError, err.Error()) 568 return 569 } 570 } else { 571 r = f 572 } 573 defer r.Close() 574 575 w, err := writer.Create(art.ArtifactPath) 576 if err != nil { 577 ctx.Error(http.StatusInternalServerError, err.Error()) 578 return 579 } 580 if _, err := io.Copy(w, r); err != nil { 581 ctx.Error(http.StatusInternalServerError, err.Error()) 582 return 583 } 584 } 585 } 586 587 func DisableWorkflowFile(ctx *context_module.Context) { 588 disableOrEnableWorkflowFile(ctx, false) 589 } 590 591 func EnableWorkflowFile(ctx *context_module.Context) { 592 disableOrEnableWorkflowFile(ctx, true) 593 } 594 595 func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { 596 workflow := ctx.FormString("workflow") 597 if len(workflow) == 0 { 598 ctx.ServerError("workflow", nil) 599 return 600 } 601 602 cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) 603 cfg := cfgUnit.ActionsConfig() 604 605 if isEnable { 606 cfg.EnableWorkflow(workflow) 607 } else { 608 cfg.DisableWorkflow(workflow) 609 } 610 611 if err := repo_model.UpdateRepoUnit(cfgUnit); err != nil { 612 ctx.ServerError("UpdateRepoUnit", err) 613 return 614 } 615 616 if isEnable { 617 ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow)) 618 } else { 619 ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow)) 620 } 621 622 redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(workflow), 623 url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status"))) 624 ctx.JSONRedirect(redirectURL) 625 }