mirror of https://github.com/go-gitea/gitea
Implement actions (#21937)
Close #13539. Co-authored by: @lunny @appleboy @fuxiaohei and others. Related projects: - https://gitea.com/gitea/actions-proto-def - https://gitea.com/gitea/actions-proto-go - https://gitea.com/gitea/act - https://gitea.com/gitea/act_runner ### Summary The target of this PR is to bring a basic implementation of "Actions", an internal CI/CD system of Gitea. That means even though it has been merged, the state of the feature is **EXPERIMENTAL**, and please note that: - It is disabled by default; - It shouldn't be used in a production environment currently; - It shouldn't be used in a public Gitea instance currently; - Breaking changes may be made before it's stable. **Please comment on #13539 if you have any different product design ideas**, all decisions reached there will be adopted here. But in this PR, we don't talk about **naming, feature-creep or alternatives**. ### ⚠️ Breaking `gitea-actions` will become a reserved user name. If a user with the name already exists in the database, it is recommended to rename it. ### Some important reviews - What is `DEFAULT_ACTIONS_URL` in `app.ini` for? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1055954954 - Why the api for runners is not under the normal `/api/v1` prefix? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1061173592 - Why DBFS? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1061301178 - Why ignore events triggered by `gitea-actions` bot? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1063254103 - Why there's no permission control for actions? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1090229868 ### What it looks like <details> #### Manage runners <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205870657-c72f590e-2e08-4cd4-be7f-2e0abb299bbf.png"> #### List runs <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205872794-50fde990-2b45-48c1-a178-908e4ec5b627.png"> #### View logs <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205872501-9b7b9000-9542-4991-8f55-18ccdada77c3.png"> </details> ### How to try it <details> #### 1. Start Gitea Clone this branch and [install from source](https://docs.gitea.io/en-us/install-from-source). Add additional configurations in `app.ini` to enable Actions: ```ini [actions] ENABLED = true ``` Start it. If all is well, you'll see the management page of runners: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205877365-8e30a780-9b10-4154-b3e8-ee6c3cb35a59.png"> #### 2. Start runner Clone the [act_runner](https://gitea.com/gitea/act_runner), and follow the [README](https://gitea.com/gitea/act_runner/src/branch/main/README.md) to start it. If all is well, you'll see a new runner has been added: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205878000-216f5937-e696-470d-b66c-8473987d91c3.png"> #### 3. Enable actions for a repo Create a new repo or open an existing one, check the `Actions` checkbox in settings and submit. <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205879705-53e09208-73c0-4b3e-a123-2dcf9aba4b9c.png"> <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205879383-23f3d08f-1a85-41dd-a8b3-54e2ee6453e8.png"> If all is well, you'll see a new tab "Actions": <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205881648-a8072d8c-5803-4d76-b8a8-9b2fb49516c1.png"> #### 4. Upload workflow files Upload some workflow files to `.gitea/workflows/xxx.yaml`, you can follow the [quickstart](https://docs.github.com/en/actions/quickstart) of GitHub Actions. Yes, Gitea Actions is compatible with GitHub Actions in most cases, you can use the same demo: ```yaml name: GitHub Actions Demo run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 on: [push] jobs: Explore-GitHub-Actions: runs-on: ubuntu-latest steps: - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." - name: Check out repository code uses: actions/checkout@v3 - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - run: echo "🖥️ The workflow is now ready to test your code on the runner." - name: List files in the repository run: | ls ${{ github.workspace }} - run: echo "🍏 This job's status is ${{ job.status }}." ``` If all is well, you'll see a new run in `Actions` tab: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205884473-79a874bc-171b-4aaf-acd5-0241a45c3b53.png"> #### 5. Check the logs of jobs Click a run and you'll see the logs: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205884800-994b0374-67f7-48ff-be9a-4c53f3141547.png"> #### 6. Go on You can try more examples in [the documents](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) of GitHub Actions, then you might find a lot of bugs. Come on, PRs are welcome. </details> See also: [Feature Preview: Gitea Actions](https://blog.gitea.io/2022/12/feature-preview-gitea-actions/) --------- Co-authored-by: a1012112796 <1012112796@qq.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: ChristopherHX <christopher.homberger@web.de> Co-authored-by: John Olheiser <john.olheiser@gmail.com>pull/22577/head
parent
b5b3e0714e
commit
4011821c94
File diff suppressed because one or more lines are too long
@ -0,0 +1,254 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/json" |
||||
api "code.gitea.io/gitea/modules/structs" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
webhook_module "code.gitea.io/gitea/modules/webhook" |
||||
|
||||
"github.com/nektos/act/pkg/jobparser" |
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
// ActionRun represents a run of a workflow file
|
||||
type ActionRun struct { |
||||
ID int64 |
||||
Title string |
||||
RepoID int64 `xorm:"index unique(repo_index)"` |
||||
Repo *repo_model.Repository `xorm:"-"` |
||||
OwnerID int64 `xorm:"index"` |
||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
|
||||
TriggerUserID int64 |
||||
TriggerUser *user_model.User `xorm:"-"` |
||||
Ref string |
||||
CommitSHA string |
||||
IsForkPullRequest bool |
||||
Event webhook_module.HookEventType |
||||
EventPayload string `xorm:"LONGTEXT"` |
||||
Status Status `xorm:"index"` |
||||
Started timeutil.TimeStamp |
||||
Stopped timeutil.TimeStamp |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionRun)) |
||||
db.RegisterModel(new(ActionRunIndex)) |
||||
} |
||||
|
||||
func (run *ActionRun) HTMLURL() string { |
||||
if run.Repo == nil { |
||||
return "" |
||||
} |
||||
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(), run.Index) |
||||
} |
||||
|
||||
func (run *ActionRun) Link() string { |
||||
if run.Repo == nil { |
||||
return "" |
||||
} |
||||
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.Index) |
||||
} |
||||
|
||||
// LoadAttributes load Repo TriggerUser if not loaded
|
||||
func (run *ActionRun) LoadAttributes(ctx context.Context) error { |
||||
if run == nil { |
||||
return nil |
||||
} |
||||
|
||||
if run.Repo == nil { |
||||
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
run.Repo = repo |
||||
} |
||||
if err := run.Repo.LoadAttributes(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if run.TriggerUser == nil { |
||||
u, err := user_model.GetPossibleUserByID(ctx, run.TriggerUserID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
run.TriggerUser = u |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (run *ActionRun) Duration() time.Duration { |
||||
return calculateDuration(run.Started, run.Stopped, run.Status) |
||||
} |
||||
|
||||
func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) { |
||||
if run.Event == webhook_module.HookEventPush { |
||||
var payload api.PushPayload |
||||
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil { |
||||
return nil, err |
||||
} |
||||
return &payload, nil |
||||
} |
||||
return nil, fmt.Errorf("event %s is not a push event", run.Event) |
||||
} |
||||
|
||||
func updateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error { |
||||
_, err := db.GetEngine(ctx).ID(repo.ID). |
||||
SetExpr("num_action_runs", |
||||
builder.Select("count(*)").From("action_run"). |
||||
Where(builder.Eq{"repo_id": repo.ID}), |
||||
). |
||||
SetExpr("num_closed_action_runs", |
||||
builder.Select("count(*)").From("action_run"). |
||||
Where(builder.Eq{ |
||||
"repo_id": repo.ID, |
||||
}.And( |
||||
builder.In("status", |
||||
StatusSuccess, |
||||
StatusFailure, |
||||
StatusCancelled, |
||||
StatusSkipped, |
||||
), |
||||
), |
||||
), |
||||
). |
||||
Update(repo) |
||||
return err |
||||
} |
||||
|
||||
// InsertRun inserts a run
|
||||
func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error { |
||||
ctx, commiter, err := db.TxContext(ctx) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer commiter.Close() |
||||
|
||||
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
run.Index = index |
||||
|
||||
if run.Status.IsUnknown() { |
||||
run.Status = StatusWaiting |
||||
} |
||||
|
||||
if err := db.Insert(ctx, run); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if run.Repo == nil { |
||||
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
run.Repo = repo |
||||
} |
||||
|
||||
if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil { |
||||
return err |
||||
} |
||||
|
||||
runJobs := make([]*ActionRunJob, 0, len(jobs)) |
||||
for _, v := range jobs { |
||||
id, job := v.Job() |
||||
needs := job.Needs() |
||||
job.EraseNeeds() |
||||
payload, _ := v.Marshal() |
||||
status := StatusWaiting |
||||
if len(needs) > 0 { |
||||
status = StatusBlocked |
||||
} |
||||
runJobs = append(runJobs, &ActionRunJob{ |
||||
RunID: run.ID, |
||||
RepoID: run.RepoID, |
||||
OwnerID: run.OwnerID, |
||||
CommitSHA: run.CommitSHA, |
||||
IsForkPullRequest: run.IsForkPullRequest, |
||||
Name: job.Name, |
||||
WorkflowPayload: payload, |
||||
JobID: id, |
||||
Needs: needs, |
||||
RunsOn: job.RunsOn(), |
||||
Status: status, |
||||
}) |
||||
} |
||||
if err := db.Insert(ctx, runJobs); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return commiter.Commit() |
||||
} |
||||
|
||||
func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) { |
||||
var run ActionRun |
||||
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("run with id %d: %w", id, util.ErrNotExist) |
||||
} |
||||
|
||||
return &run, nil |
||||
} |
||||
|
||||
func GetRunByIndex(ctx context.Context, repoID, index int64) (*ActionRun, error) { |
||||
run := &ActionRun{ |
||||
RepoID: repoID, |
||||
Index: index, |
||||
} |
||||
has, err := db.GetEngine(ctx).Get(run) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("run with index %d %d: %w", repoID, index, util.ErrNotExist) |
||||
} |
||||
|
||||
return run, nil |
||||
} |
||||
|
||||
func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error { |
||||
sess := db.GetEngine(ctx).ID(run.ID) |
||||
if len(cols) > 0 { |
||||
sess.Cols(cols...) |
||||
} |
||||
_, err := sess.Update(run) |
||||
|
||||
if run.Status != 0 || util.SliceContains(cols, "status") { |
||||
if run.RepoID == 0 { |
||||
run, err = GetRunByID(ctx, run.ID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
} |
||||
if run.Repo == nil { |
||||
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
run.Repo = repo |
||||
} |
||||
if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return err |
||||
} |
||||
|
||||
type ActionRunIndex db.ResourceIndex |
@ -0,0 +1,163 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
// ActionRunJob represents a job of a run
|
||||
type ActionRunJob struct { |
||||
ID int64 |
||||
RunID int64 `xorm:"index"` |
||||
Run *ActionRun `xorm:"-"` |
||||
RepoID int64 `xorm:"index"` |
||||
OwnerID int64 `xorm:"index"` |
||||
CommitSHA string `xorm:"index"` |
||||
IsForkPullRequest bool |
||||
Name string `xorm:"VARCHAR(255)"` |
||||
Attempt int64 |
||||
WorkflowPayload []byte |
||||
JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id
|
||||
Needs []string `xorm:"JSON TEXT"` |
||||
RunsOn []string `xorm:"JSON TEXT"` |
||||
TaskID int64 // the latest task of the job
|
||||
Status Status `xorm:"index"` |
||||
Started timeutil.TimeStamp |
||||
Stopped timeutil.TimeStamp |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated index"` |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionRunJob)) |
||||
} |
||||
|
||||
func (job *ActionRunJob) Duration() time.Duration { |
||||
return calculateDuration(job.Started, job.Stopped, job.Status) |
||||
} |
||||
|
||||
func (job *ActionRunJob) LoadRun(ctx context.Context) error { |
||||
if job.Run == nil { |
||||
run, err := GetRunByID(ctx, job.RunID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
job.Run = run |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// LoadAttributes load Run if not loaded
|
||||
func (job *ActionRunJob) LoadAttributes(ctx context.Context) error { |
||||
if job == nil { |
||||
return nil |
||||
} |
||||
|
||||
if err := job.LoadRun(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return job.Run.LoadAttributes(ctx) |
||||
} |
||||
|
||||
func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) { |
||||
var job ActionRunJob |
||||
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&job) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("run job with id %d: %w", id, util.ErrNotExist) |
||||
} |
||||
|
||||
return &job, nil |
||||
} |
||||
|
||||
func GetRunJobsByRunID(ctx context.Context, runID int64) ([]*ActionRunJob, error) { |
||||
var jobs []*ActionRunJob |
||||
if err := db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("id").Find(&jobs); err != nil { |
||||
return nil, err |
||||
} |
||||
return jobs, nil |
||||
} |
||||
|
||||
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) { |
||||
e := db.GetEngine(ctx) |
||||
|
||||
sess := e.ID(job.ID) |
||||
if len(cols) > 0 { |
||||
sess.Cols(cols...) |
||||
} |
||||
|
||||
if cond != nil { |
||||
sess.Where(cond) |
||||
} |
||||
|
||||
affected, err := sess.Update(job) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
|
||||
if affected == 0 || (!util.SliceContains(cols, "status") && job.Status == 0) { |
||||
return affected, nil |
||||
} |
||||
|
||||
if job.RunID == 0 { |
||||
var err error |
||||
if job, err = GetRunJobByID(ctx, job.ID); err != nil { |
||||
return affected, err |
||||
} |
||||
} |
||||
|
||||
jobs, err := GetRunJobsByRunID(ctx, job.RunID) |
||||
if err != nil { |
||||
return affected, err |
||||
} |
||||
|
||||
runStatus := aggregateJobStatus(jobs) |
||||
|
||||
run := &ActionRun{ |
||||
ID: job.RunID, |
||||
Status: runStatus, |
||||
} |
||||
if runStatus.IsDone() { |
||||
run.Stopped = timeutil.TimeStampNow() |
||||
} |
||||
return affected, UpdateRun(ctx, run) |
||||
} |
||||
|
||||
func aggregateJobStatus(jobs []*ActionRunJob) Status { |
||||
allDone := true |
||||
allWaiting := true |
||||
hasFailure := false |
||||
for _, job := range jobs { |
||||
if !job.Status.IsDone() { |
||||
allDone = false |
||||
} |
||||
if job.Status != StatusWaiting { |
||||
allWaiting = false |
||||
} |
||||
if job.Status == StatusFailure || job.Status == StatusCancelled { |
||||
hasFailure = true |
||||
} |
||||
} |
||||
if allDone { |
||||
if hasFailure { |
||||
return StatusFailure |
||||
} |
||||
return StatusSuccess |
||||
} |
||||
if allWaiting { |
||||
return StatusWaiting |
||||
} |
||||
return StatusRunning |
||||
} |
@ -0,0 +1,99 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/container" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
type ActionJobList []*ActionRunJob |
||||
|
||||
func (jobs ActionJobList) GetRunIDs() []int64 { |
||||
ids := make(container.Set[int64], len(jobs)) |
||||
for _, j := range jobs { |
||||
if j.RunID == 0 { |
||||
continue |
||||
} |
||||
ids.Add(j.RunID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (jobs ActionJobList) LoadRuns(ctx context.Context, withRepo bool) error { |
||||
runIDs := jobs.GetRunIDs() |
||||
runs := make(map[int64]*ActionRun, len(runIDs)) |
||||
if err := db.GetEngine(ctx).In("id", runIDs).Find(&runs); err != nil { |
||||
return err |
||||
} |
||||
for _, j := range jobs { |
||||
if j.RunID > 0 && j.Run == nil { |
||||
j.Run = runs[j.RunID] |
||||
} |
||||
} |
||||
if withRepo { |
||||
var runsList RunList = make([]*ActionRun, 0, len(runs)) |
||||
for _, r := range runs { |
||||
runsList = append(runsList, r) |
||||
} |
||||
return runsList.LoadRepos() |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) error { |
||||
return jobs.LoadRuns(ctx, withRepo) |
||||
} |
||||
|
||||
type FindRunJobOptions struct { |
||||
db.ListOptions |
||||
RunID int64 |
||||
RepoID int64 |
||||
OwnerID int64 |
||||
CommitSHA string |
||||
Statuses []Status |
||||
UpdatedBefore timeutil.TimeStamp |
||||
} |
||||
|
||||
func (opts FindRunJobOptions) toConds() builder.Cond { |
||||
cond := builder.NewCond() |
||||
if opts.RunID > 0 { |
||||
cond = cond.And(builder.Eq{"run_id": opts.RunID}) |
||||
} |
||||
if opts.RepoID > 0 { |
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) |
||||
} |
||||
if opts.OwnerID > 0 { |
||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) |
||||
} |
||||
if opts.CommitSHA != "" { |
||||
cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA}) |
||||
} |
||||
if len(opts.Statuses) > 0 { |
||||
cond = cond.And(builder.In("status", opts.Statuses)) |
||||
} |
||||
if opts.UpdatedBefore > 0 { |
||||
cond = cond.And(builder.Lt{"updated": opts.UpdatedBefore}) |
||||
} |
||||
return cond |
||||
} |
||||
|
||||
func FindRunJobs(ctx context.Context, opts FindRunJobOptions) (ActionJobList, int64, error) { |
||||
e := db.GetEngine(ctx).Where(opts.toConds()) |
||||
if opts.PageSize > 0 && opts.Page >= 1 { |
||||
e.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) |
||||
} |
||||
var tasks ActionJobList |
||||
total, err := e.FindAndCount(&tasks) |
||||
return tasks, total, err |
||||
} |
||||
|
||||
func CountRunJobs(ctx context.Context, opts FindRunJobOptions) (int64, error) { |
||||
return db.GetEngine(ctx).Where(opts.toConds()).Count(new(ActionRunJob)) |
||||
} |
@ -0,0 +1,107 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/container" |
||||
"code.gitea.io/gitea/modules/util" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
type RunList []*ActionRun |
||||
|
||||
// GetUserIDs returns a slice of user's id
|
||||
func (runs RunList) GetUserIDs() []int64 { |
||||
ids := make(container.Set[int64], len(runs)) |
||||
for _, run := range runs { |
||||
ids.Add(run.TriggerUserID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (runs RunList) GetRepoIDs() []int64 { |
||||
ids := make(container.Set[int64], len(runs)) |
||||
for _, run := range runs { |
||||
ids.Add(run.RepoID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (runs RunList) LoadTriggerUser(ctx context.Context) error { |
||||
userIDs := runs.GetUserIDs() |
||||
users := make(map[int64]*user_model.User, len(userIDs)) |
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil { |
||||
return err |
||||
} |
||||
for _, run := range runs { |
||||
if run.TriggerUserID == user_model.ActionsUserID { |
||||
run.TriggerUser = user_model.NewActionsUser() |
||||
} else { |
||||
run.TriggerUser = users[run.TriggerUserID] |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (runs RunList) LoadRepos() error { |
||||
repoIDs := runs.GetRepoIDs() |
||||
repos, err := repo_model.GetRepositoriesMapByIDs(repoIDs) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, run := range runs { |
||||
run.Repo = repos[run.RepoID] |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
type FindRunOptions struct { |
||||
db.ListOptions |
||||
RepoID int64 |
||||
OwnerID int64 |
||||
IsClosed util.OptionalBool |
||||
WorkflowFileName string |
||||
} |
||||
|
||||
func (opts FindRunOptions) toConds() builder.Cond { |
||||
cond := builder.NewCond() |
||||
if opts.RepoID > 0 { |
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) |
||||
} |
||||
if opts.OwnerID > 0 { |
||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) |
||||
} |
||||
if opts.IsClosed.IsFalse() { |
||||
cond = cond.And(builder.Eq{"status": StatusWaiting}.Or( |
||||
builder.Eq{"status": StatusRunning})) |
||||
} else if opts.IsClosed.IsTrue() { |
||||
cond = cond.And( |
||||
builder.Neq{"status": StatusWaiting}.And( |
||||
builder.Neq{"status": StatusRunning})) |
||||
} |
||||
if opts.WorkflowFileName != "" { |
||||
cond = cond.And(builder.Eq{"workflow_id": opts.WorkflowFileName}) |
||||
} |
||||
return cond |
||||
} |
||||
|
||||
func FindRuns(ctx context.Context, opts FindRunOptions) (RunList, int64, error) { |
||||
e := db.GetEngine(ctx).Where(opts.toConds()) |
||||
if opts.PageSize > 0 && opts.Page >= 1 { |
||||
e.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) |
||||
} |
||||
var runs RunList |
||||
total, err := e.Desc("id").FindAndCount(&runs) |
||||
return runs, total, err |
||||
} |
||||
|
||||
func CountRuns(ctx context.Context, opts FindRunOptions) (int64, error) { |
||||
return db.GetEngine(ctx).Where(opts.toConds()).Count(new(ActionRun)) |
||||
} |
@ -0,0 +1,252 @@ |
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"strings" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/translation" |
||||
"code.gitea.io/gitea/modules/util" |
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" |
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
// ActionRunner represents runner machines
|
||||
type ActionRunner struct { |
||||
ID int64 |
||||
UUID string `xorm:"CHAR(36) UNIQUE"` |
||||
Name string `xorm:"VARCHAR(255)"` |
||||
OwnerID int64 `xorm:"index"` // org level runner, 0 means system
|
||||
Owner *user_model.User `xorm:"-"` |
||||
RepoID int64 `xorm:"index"` // repo level runner, if orgid also is zero, then it's a global
|
||||
Repo *repo_model.Repository `xorm:"-"` |
||||
Description string `xorm:"TEXT"` |
||||
Base int // 0 native 1 docker 2 virtual machine
|
||||
RepoRange string // glob match which repositories could use this runner
|
||||
|
||||
Token string `xorm:"-"` |
||||
TokenHash string `xorm:"UNIQUE"` // sha256 of token
|
||||
TokenSalt string |
||||
// TokenLastEight string `xorm:"token_last_eight"` // it's unnecessary because we don't find runners by token
|
||||
|
||||
LastOnline timeutil.TimeStamp `xorm:"index"` |
||||
LastActive timeutil.TimeStamp `xorm:"index"` |
||||
|
||||
// Store OS and Artch.
|
||||
AgentLabels []string |
||||
// Store custom labes use defined.
|
||||
CustomLabels []string |
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
Deleted timeutil.TimeStamp `xorm:"deleted"` |
||||
} |
||||
|
||||
func (r *ActionRunner) OwnType() string { |
||||
if r.RepoID != 0 { |
||||
return fmt.Sprintf("Repo(%s)", r.Repo.FullName()) |
||||
} |
||||
if r.OwnerID != 0 { |
||||
return fmt.Sprintf("Org(%s)", r.Owner.Name) |
||||
} |
||||
return "Global" |
||||
} |
||||
|
||||
func (r *ActionRunner) Status() runnerv1.RunnerStatus { |
||||
if time.Since(r.LastOnline.AsTime()) > time.Minute { |
||||
return runnerv1.RunnerStatus_RUNNER_STATUS_OFFLINE |
||||
} |
||||
if time.Since(r.LastActive.AsTime()) > 10*time.Second { |
||||
return runnerv1.RunnerStatus_RUNNER_STATUS_IDLE |
||||
} |
||||
return runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE |
||||
} |
||||
|
||||
func (r *ActionRunner) StatusName() string { |
||||
return strings.ToLower(strings.TrimPrefix(r.Status().String(), "RUNNER_STATUS_")) |
||||
} |
||||
|
||||
func (r *ActionRunner) StatusLocaleName(lang translation.Locale) string { |
||||
return lang.Tr("actions.runners.status." + r.StatusName()) |
||||
} |
||||
|
||||
func (r *ActionRunner) IsOnline() bool { |
||||
status := r.Status() |
||||
if status == runnerv1.RunnerStatus_RUNNER_STATUS_IDLE || status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE { |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
// AllLabels returns agent and custom labels
|
||||
func (r *ActionRunner) AllLabels() []string { |
||||
return append(r.AgentLabels, r.CustomLabels...) |
||||
} |
||||
|
||||
// Editable checks if the runner is editable by the user
|
||||
func (r *ActionRunner) Editable(ownerID, repoID int64) bool { |
||||
if ownerID == 0 && repoID == 0 { |
||||
return true |
||||
} |
||||
if ownerID > 0 && r.OwnerID == ownerID { |
||||
return true |
||||
} |
||||
return repoID > 0 && r.RepoID == repoID |
||||
} |
||||
|
||||
// LoadAttributes loads the attributes of the runner
|
||||
func (r *ActionRunner) LoadAttributes(ctx context.Context) error { |
||||
if r.OwnerID > 0 { |
||||
var user user_model.User |
||||
has, err := db.GetEngine(ctx).ID(r.OwnerID).Get(&user) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if has { |
||||
r.Owner = &user |
||||
} |
||||
} |
||||
if r.RepoID > 0 { |
||||
var repo repo_model.Repository |
||||
has, err := db.GetEngine(ctx).ID(r.RepoID).Get(&repo) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if has { |
||||
r.Repo = &repo |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (r *ActionRunner) GenerateToken() (err error) { |
||||
r.Token, r.TokenSalt, r.TokenHash, _, err = generateSaltedToken() |
||||
return err |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(&ActionRunner{}) |
||||
} |
||||
|
||||
type FindRunnerOptions struct { |
||||
db.ListOptions |
||||
RepoID int64 |
||||
OwnerID int64 |
||||
Sort string |
||||
Filter string |
||||
WithAvailable bool // not only runners belong to, but also runners can be used
|
||||
} |
||||
|
||||
func (opts FindRunnerOptions) toCond() builder.Cond { |
||||
cond := builder.NewCond() |
||||
|
||||
if opts.RepoID > 0 { |
||||
c := builder.NewCond().And(builder.Eq{"repo_id": opts.RepoID}) |
||||
if opts.WithAvailable { |
||||
c = c.Or(builder.Eq{"owner_id": builder.Select("owner_id").From("repository").Where(builder.Eq{"id": opts.RepoID})}) |
||||
c = c.Or(builder.Eq{"repo_id": 0, "owner_id": 0}) |
||||
} |
||||
cond = cond.And(c) |
||||
} |
||||
if opts.OwnerID > 0 { |
||||
c := builder.NewCond().And(builder.Eq{"owner_id": opts.OwnerID}) |
||||
if opts.WithAvailable { |
||||
c = c.Or(builder.Eq{"repo_id": 0, "owner_id": 0}) |
||||
} |
||||
cond = cond.And(c) |
||||
} |
||||
|
||||
if opts.Filter != "" { |
||||
cond = cond.And(builder.Like{"name", opts.Filter}) |
||||
} |
||||
return cond |
||||
} |
||||
|
||||
func (opts FindRunnerOptions) toOrder() string { |
||||
switch opts.Sort { |
||||
case "online": |
||||
return "last_online DESC" |
||||
case "offline": |
||||
return "last_online ASC" |
||||
case "alphabetically": |
||||
return "name ASC" |
||||
} |
||||
return "last_online DESC" |
||||
} |
||||
|
||||
func CountRunners(ctx context.Context, opts FindRunnerOptions) (int64, error) { |
||||
return db.GetEngine(ctx). |
||||
Where(opts.toCond()). |
||||
Count(ActionRunner{}) |
||||
} |
||||
|
||||
func FindRunners(ctx context.Context, opts FindRunnerOptions) (runners RunnerList, err error) { |
||||
sess := db.GetEngine(ctx). |
||||
Where(opts.toCond()). |
||||
OrderBy(opts.toOrder()) |
||||
if opts.Page > 0 { |
||||
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) |
||||
} |
||||
return runners, sess.Find(&runners) |
||||
} |
||||
|
||||
// GetRunnerByUUID returns a runner via uuid
|
||||
func GetRunnerByUUID(ctx context.Context, uuid string) (*ActionRunner, error) { |
||||
var runner ActionRunner |
||||
has, err := db.GetEngine(ctx).Where("uuid=?", uuid).Get(&runner) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("runner with uuid %s: %w", uuid, util.ErrNotExist) |
||||
} |
||||
return &runner, nil |
||||
} |
||||
|
||||
// GetRunnerByID returns a runner via id
|
||||
func GetRunnerByID(ctx context.Context, id int64) (*ActionRunner, error) { |
||||
var runner ActionRunner |
||||
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&runner) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("runner with id %d: %w", id, util.ErrNotExist) |
||||
} |
||||
return &runner, nil |
||||
} |
||||
|
||||
// UpdateRunner updates runner's information.
|
||||
func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error { |
||||
e := db.GetEngine(ctx) |
||||
var err error |
||||
if len(cols) == 0 { |
||||
_, err = e.ID(r.ID).AllCols().Update(r) |
||||
} else { |
||||
_, err = e.ID(r.ID).Cols(cols...).Update(r) |
||||
} |
||||
return err |
||||
} |
||||
|
||||
// DeleteRunner deletes a runner by given ID.
|
||||
func DeleteRunner(ctx context.Context, id int64) error { |
||||
if _, err := GetRunnerByID(ctx, id); err != nil { |
||||
return err |
||||
} |
||||
|
||||
_, err := db.GetEngine(ctx).Delete(&ActionRunner{ID: id}) |
||||
return err |
||||
} |
||||
|
||||
// CreateRunner creates new runner.
|
||||
func CreateRunner(ctx context.Context, t *ActionRunner) error { |
||||
_, err := db.GetEngine(ctx).Insert(t) |
||||
return err |
||||
} |
@ -0,0 +1,77 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/container" |
||||
) |
||||
|
||||
type RunnerList []*ActionRunner |
||||
|
||||
// GetUserIDs returns a slice of user's id
|
||||
func (runners RunnerList) GetUserIDs() []int64 { |
||||
ids := make(container.Set[int64], len(runners)) |
||||
for _, runner := range runners { |
||||
if runner.OwnerID == 0 { |
||||
continue |
||||
} |
||||
ids.Add(runner.OwnerID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (runners RunnerList) LoadOwners(ctx context.Context) error { |
||||
userIDs := runners.GetUserIDs() |
||||
users := make(map[int64]*user_model.User, len(userIDs)) |
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil { |
||||
return err |
||||
} |
||||
for _, runner := range runners { |
||||
if runner.OwnerID > 0 && runner.Owner == nil { |
||||
runner.Owner = users[runner.OwnerID] |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (runners RunnerList) getRepoIDs() []int64 { |
||||
repoIDs := make(container.Set[int64], len(runners)) |
||||
for _, runner := range runners { |
||||
if runner.RepoID == 0 { |
||||
continue |
||||
} |
||||
if _, ok := repoIDs[runner.RepoID]; !ok { |
||||
repoIDs[runner.RepoID] = struct{}{} |
||||
} |
||||
} |
||||
return repoIDs.Values() |
||||
} |
||||
|
||||
func (runners RunnerList) LoadRepos(ctx context.Context) error { |
||||
repoIDs := runners.getRepoIDs() |
||||
repos := make(map[int64]*repo_model.Repository, len(repoIDs)) |
||||
if err := db.GetEngine(ctx).In("id", repoIDs).Find(&repos); err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, runner := range runners { |
||||
if runner.RepoID > 0 && runner.Repo == nil { |
||||
runner.Repo = repos[runner.RepoID] |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (runners RunnerList) LoadAttributes(ctx context.Context) error { |
||||
if err := runners.LoadOwners(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return runners.LoadRepos(ctx) |
||||
} |
@ -0,0 +1,86 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
) |
||||
|
||||
// ActionRunnerToken represents runner tokens
|
||||
type ActionRunnerToken struct { |
||||
ID int64 |
||||
Token string `xorm:"UNIQUE"` |
||||
OwnerID int64 `xorm:"index"` // org level runner, 0 means system
|
||||
Owner *user_model.User `xorm:"-"` |
||||
RepoID int64 `xorm:"index"` // repo level runner, if orgid also is zero, then it's a global
|
||||
Repo *repo_model.Repository `xorm:"-"` |
||||
IsActive bool |
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
Deleted timeutil.TimeStamp `xorm:"deleted"` |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionRunnerToken)) |
||||
} |
||||
|
||||
// GetRunnerToken returns a action runner via token
|
||||
func GetRunnerToken(ctx context.Context, token string) (*ActionRunnerToken, error) { |
||||
var runnerToken ActionRunnerToken |
||||
has, err := db.GetEngine(ctx).Where("token=?", token).Get(&runnerToken) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("runner token %q: %w", token, util.ErrNotExist) |
||||
} |
||||
return &runnerToken, nil |
||||
} |
||||
|
||||
// UpdateRunnerToken updates runner token information.
|
||||
func UpdateRunnerToken(ctx context.Context, r *ActionRunnerToken, cols ...string) (err error) { |
||||
e := db.GetEngine(ctx) |
||||
|
||||
if len(cols) == 0 { |
||||
_, err = e.ID(r.ID).AllCols().Update(r) |
||||
} else { |
||||
_, err = e.ID(r.ID).Cols(cols...).Update(r) |
||||
} |
||||
return err |
||||
} |
||||
|
||||
// NewRunnerToken creates a new runner token
|
||||
func NewRunnerToken(ctx context.Context, ownerID, repoID int64) (*ActionRunnerToken, error) { |
||||
token, err := util.CryptoRandomString(40) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
runnerToken := &ActionRunnerToken{ |
||||
OwnerID: ownerID, |
||||
RepoID: repoID, |
||||
IsActive: false, |
||||
Token: token, |
||||
} |
||||
_, err = db.GetEngine(ctx).Insert(runnerToken) |
||||
return runnerToken, err |
||||
} |
||||
|
||||
// GetUnactivatedRunnerToken returns a unactivated runner token
|
||||
func GetUnactivatedRunnerToken(ctx context.Context, ownerID, repoID int64) (*ActionRunnerToken, error) { |
||||
var runnerToken ActionRunnerToken |
||||
has, err := db.GetEngine(ctx).Where("owner_id=? AND repo_id=? AND is_active=?", ownerID, repoID, false).OrderBy("id DESC").Get(&runnerToken) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("runner token: %w", util.ErrNotExist) |
||||
} |
||||
return &runnerToken, nil |
||||
} |
@ -0,0 +1,100 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/modules/translation" |
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" |
||||
) |
||||
|
||||
// Status represents the status of ActionRun, ActionRunJob, ActionTask, or ActionTaskStep
|
||||
type Status int |
||||
|
||||
const ( |
||||
StatusUnknown Status = iota // 0, consistent with runnerv1.Result_RESULT_UNSPECIFIED
|
||||
StatusSuccess // 1, consistent with runnerv1.Result_RESULT_SUCCESS
|
||||
StatusFailure // 2, consistent with runnerv1.Result_RESULT_FAILURE
|
||||
StatusCancelled // 3, consistent with runnerv1.Result_RESULT_CANCELLED
|
||||
StatusSkipped // 4, consistent with runnerv1.Result_RESULT_SKIPPED
|
||||
StatusWaiting // 5, isn't a runnerv1.Result
|
||||
StatusRunning // 6, isn't a runnerv1.Result
|
||||
StatusBlocked // 7, isn't a runnerv1.Result
|
||||
) |
||||
|
||||
var statusNames = map[Status]string{ |
||||
StatusUnknown: "unknown", |
||||
StatusWaiting: "waiting", |
||||
StatusRunning: "running", |
||||
StatusSuccess: "success", |
||||
StatusFailure: "failure", |
||||
StatusCancelled: "cancelled", |
||||
StatusSkipped: "skipped", |
||||
StatusBlocked: "blocked", |
||||
} |
||||
|
||||
// String returns the string name of the Status
|
||||
func (s Status) String() string { |
||||
return statusNames[s] |
||||
} |
||||
|
||||
// LocaleString returns the locale string name of the Status
|
||||
func (s Status) LocaleString(lang translation.Locale) string { |
||||
return lang.Tr("actions.status." + s.String()) |
||||
} |
||||
|
||||
// IsDone returns whether the Status is final
|
||||
func (s Status) IsDone() bool { |
||||
return s.In(StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped) |
||||
} |
||||
|
||||
// HasRun returns whether the Status is a result of running
|
||||
func (s Status) HasRun() bool { |
||||
return s.In(StatusSuccess, StatusFailure) |
||||
} |
||||
|
||||
func (s Status) IsUnknown() bool { |
||||
return s == StatusUnknown |
||||
} |
||||
|
||||
func (s Status) IsSuccess() bool { |
||||
return s == StatusSuccess |
||||
} |
||||
|
||||
func (s Status) IsFailure() bool { |
||||
return s == StatusFailure |
||||
} |
||||
|
||||
func (s Status) IsCancelled() bool { |
||||
return s == StatusCancelled |
||||
} |
||||
|
||||
func (s Status) IsSkipped() bool { |
||||
return s == StatusSkipped |
||||
} |
||||
|
||||
func (s Status) IsWaiting() bool { |
||||
return s == StatusWaiting |
||||
} |
||||
|
||||
func (s Status) IsRunning() bool { |
||||
return s == StatusRunning |
||||
} |
||||
|
||||
// In returns whether s is one of the given statuses
|
||||
func (s Status) In(statuses ...Status) bool { |
||||
for _, v := range statuses { |
||||
if s == v { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
|
||||
func (s Status) AsResult() runnerv1.Result { |
||||
if s.IsDone() { |
||||
return runnerv1.Result(s) |
||||
} |
||||
return runnerv1.Result_RESULT_UNSPECIFIED |
||||
} |
@ -0,0 +1,504 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/subtle" |
||||
"fmt" |
||||
"time" |
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/container" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" |
||||
lru "github.com/hashicorp/golang-lru" |
||||
"github.com/nektos/act/pkg/jobparser" |
||||
"google.golang.org/protobuf/types/known/timestamppb" |
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
// ActionTask represents a distribution of job
|
||||
type ActionTask struct { |
||||
ID int64 |
||||
JobID int64 |
||||
Job *ActionRunJob `xorm:"-"` |
||||
Steps []*ActionTaskStep `xorm:"-"` |
||||
Attempt int64 |
||||
RunnerID int64 `xorm:"index"` |
||||
Status Status `xorm:"index"` |
||||
Started timeutil.TimeStamp `xorm:"index"` |
||||
Stopped timeutil.TimeStamp |
||||
|
||||
RepoID int64 `xorm:"index"` |
||||
OwnerID int64 `xorm:"index"` |
||||
CommitSHA string `xorm:"index"` |
||||
IsForkPullRequest bool |
||||
|
||||
Token string `xorm:"-"` |
||||
TokenHash string `xorm:"UNIQUE"` // sha256 of token
|
||||
TokenSalt string |
||||
TokenLastEight string `xorm:"index token_last_eight"` |
||||
|
||||
LogFilename string // file name of log
|
||||
LogInStorage bool // read log from database or from storage
|
||||
LogLength int64 // lines count
|
||||
LogSize int64 // blob size
|
||||
LogIndexes LogIndexes `xorm:"LONGBLOB"` // line number to offset
|
||||
LogExpired bool // files that are too old will be deleted
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated index"` |
||||
} |
||||
|
||||
var successfulTokenTaskCache *lru.Cache |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionTask), func() error { |
||||
if setting.SuccessfulTokensCacheSize > 0 { |
||||
var err error |
||||
successfulTokenTaskCache, err = lru.New(setting.SuccessfulTokensCacheSize) |
||||
if err != nil { |
||||
return fmt.Errorf("unable to allocate Task cache: %v", err) |
||||
} |
||||
} else { |
||||
successfulTokenTaskCache = nil |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
func (task *ActionTask) Duration() time.Duration { |
||||
return calculateDuration(task.Started, task.Stopped, task.Status) |
||||
} |
||||
|
||||
func (task *ActionTask) IsStopped() bool { |
||||
return task.Stopped > 0 |
||||
} |
||||
|
||||
func (task *ActionTask) GetRunLink() string { |
||||
if task.Job == nil || task.Job.Run == nil { |
||||
return "" |
||||
} |
||||
return task.Job.Run.Link() |
||||
} |
||||
|
||||
func (task *ActionTask) GetCommitLink() string { |
||||
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil { |
||||
return "" |
||||
} |
||||
return task.Job.Run.Repo.CommitLink(task.CommitSHA) |
||||
} |
||||
|
||||
func (task *ActionTask) GetRepoName() string { |
||||
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil { |
||||
return "" |
||||
} |
||||
return task.Job.Run.Repo.FullName() |
||||
} |
||||
|
||||
func (task *ActionTask) GetRepoLink() string { |
||||
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil { |
||||
return "" |
||||
} |
||||
return task.Job.Run.Repo.Link() |
||||
} |
||||
|
||||
func (task *ActionTask) LoadJob(ctx context.Context) error { |
||||
if task.Job == nil { |
||||
job, err := GetRunJobByID(ctx, task.JobID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
task.Job = job |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// LoadAttributes load Job Steps if not loaded
|
||||
func (task *ActionTask) LoadAttributes(ctx context.Context) error { |
||||
if task == nil { |
||||
return nil |
||||
} |
||||
if err := task.LoadJob(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err := task.Job.LoadAttributes(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if task.Steps == nil { // be careful, an empty slice (not nil) also means loaded
|
||||
steps, err := GetTaskStepsByTaskID(ctx, task.ID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
task.Steps = steps |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (task *ActionTask) GenerateToken() (err error) { |
||||
task.Token, task.TokenSalt, task.TokenHash, task.TokenLastEight, err = generateSaltedToken() |
||||
return err |
||||
} |
||||
|
||||
func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) { |
||||
var task ActionTask |
||||
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&task) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("task with id %d: %w", id, util.ErrNotExist) |
||||
} |
||||
|
||||
return &task, nil |
||||
} |
||||
|
||||
func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) { |
||||
errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist) |
||||
if token == "" { |
||||
return nil, errNotExist |
||||
} |
||||
// A token is defined as being SHA1 sum these are 40 hexadecimal bytes long
|
||||
if len(token) != 40 { |
||||
return nil, errNotExist |
||||
} |
||||
for _, x := range []byte(token) { |
||||
if x < '0' || (x > '9' && x < 'a') || x > 'f' { |
||||
return nil, errNotExist |
||||
} |
||||
} |
||||
|
||||
lastEight := token[len(token)-8:] |
||||
|
||||
if id := getTaskIDFromCache(token); id > 0 { |
||||
task := &ActionTask{ |
||||
TokenLastEight: lastEight, |
||||
} |
||||
// Re-get the task from the db in case it has been deleted in the intervening period
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(task) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if has { |
||||
return task, nil |
||||
} |
||||
successfulTokenTaskCache.Remove(token) |
||||
} |
||||
|
||||
var tasks []*ActionTask |
||||
err := db.GetEngine(ctx).Where("token_last_eight = ? AND status = ?", lastEight, StatusRunning).Find(&tasks) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if len(tasks) == 0 { |
||||
return nil, errNotExist |
||||
} |
||||
|
||||
for _, t := range tasks { |
||||
tempHash := auth_model.HashToken(token, t.TokenSalt) |
||||
if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(tempHash)) == 1 { |
||||
if successfulTokenTaskCache != nil { |
||||
successfulTokenTaskCache.Add(token, t.ID) |
||||
} |
||||
return t, nil |
||||
} |
||||
} |
||||
return nil, errNotExist |
||||
} |
||||
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) { |
||||
dbCtx, commiter, err := db.TxContext(ctx) |
||||
if err != nil { |
||||
return nil, false, err |
||||
} |
||||
defer commiter.Close() |
||||
ctx = dbCtx.WithContext(ctx) |
||||
|
||||
e := db.GetEngine(ctx) |
||||
|
||||
jobCond := builder.NewCond() |
||||
if runner.RepoID != 0 { |
||||
jobCond = builder.Eq{"repo_id": runner.RepoID} |
||||
} else if runner.OwnerID != 0 { |
||||
jobCond = builder.In("repo_id", builder.Select("id").From("repository").Where(builder.Eq{"owner_id": runner.OwnerID})) |
||||
} |
||||
if jobCond.IsValid() { |
||||
jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond)) |
||||
} |
||||
|
||||
var jobs []*ActionRunJob |
||||
if err := e.Where("task_id=? AND status=?", 0, StatusWaiting).And(jobCond).Asc("id").Find(&jobs); err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
// TODO: a more efficient way to filter labels
|
||||
var job *ActionRunJob |
||||
labels := runner.AgentLabels |
||||
labels = append(labels, runner.CustomLabels...) |
||||
log.Trace("runner labels: %v", labels) |
||||
for _, v := range jobs { |
||||
if isSubset(labels, v.RunsOn) { |
||||
job = v |
||||
break |
||||
} |
||||
} |
||||
if job == nil { |
||||
return nil, false, nil |
||||
} |
||||
if err := job.LoadAttributes(ctx); err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
now := timeutil.TimeStampNow() |
||||
job.Attempt++ |
||||
job.Started = now |
||||
job.Status = StatusRunning |
||||
|
||||
task := &ActionTask{ |
||||
JobID: job.ID, |
||||
Attempt: job.Attempt, |
||||
RunnerID: runner.ID, |
||||
Started: now, |
||||
Status: StatusRunning, |
||||
RepoID: job.RepoID, |
||||
OwnerID: job.OwnerID, |
||||
CommitSHA: job.CommitSHA, |
||||
IsForkPullRequest: job.IsForkPullRequest, |
||||
} |
||||
if err := task.GenerateToken(); err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
var workflowJob *jobparser.Job |
||||
if gots, err := jobparser.Parse(job.WorkflowPayload); err != nil { |
||||
return nil, false, fmt.Errorf("parse workflow of job %d: %w", job.ID, err) |
||||
} else if len(gots) != 1 { |
||||
return nil, false, fmt.Errorf("workflow of job %d: not signle workflow", job.ID) |
||||
} else { |
||||
_, workflowJob = gots[0].Job() |
||||
} |
||||
|
||||
if _, err := e.Insert(task); err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
task.LogFilename = logFileName(job.Run.Repo.FullName(), task.ID) |
||||
if _, err := e.ID(task.ID).Cols("log_filename").Update(task); err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
if len(workflowJob.Steps) > 0 { |
||||
steps := make([]*ActionTaskStep, len(workflowJob.Steps)) |
||||
for i, v := range workflowJob.Steps { |
||||
steps[i] = &ActionTaskStep{ |
||||
Name: v.String(), |
||||
TaskID: task.ID, |
||||
Index: int64(i), |
||||
RepoID: task.RepoID, |
||||
Status: StatusWaiting, |
||||
} |
||||
} |
||||
if _, err := e.Insert(steps); err != nil { |
||||
return nil, false, err |
||||
} |
||||
task.Steps = steps |
||||
} |
||||
|
||||
job.TaskID = task.ID |
||||
if n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}); err != nil { |
||||
return nil, false, err |
||||
} else if n != 1 { |
||||
return nil, false, nil |
||||
} |
||||
|
||||
if job.Run.Status.IsWaiting() { |
||||
job.Run.Status = StatusRunning |
||||
job.Run.Started = now |
||||
if err := UpdateRun(ctx, job.Run, "status", "started"); err != nil { |
||||
return nil, false, err |
||||
} |
||||
} |
||||
|
||||
task.Job = job |
||||
|
||||
if err := commiter.Commit(); err != nil { |
||||
return nil, false, err |
||||
} |
||||
|
||||
return task, true, nil |
||||
} |
||||
|
||||
func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error { |
||||
sess := db.GetEngine(ctx).ID(task.ID) |
||||
if len(cols) > 0 { |
||||
sess.Cols(cols...) |
||||
} |
||||
_, err := sess.Update(task) |
||||
return err |
||||
} |
||||
|
||||
func UpdateTaskByState(ctx context.Context, state *runnerv1.TaskState) (*ActionTask, error) { |
||||
stepStates := map[int64]*runnerv1.StepState{} |
||||
for _, v := range state.Steps { |
||||
stepStates[v.Id] = v |
||||
} |
||||
|
||||
ctx, commiter, err := db.TxContext(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer commiter.Close() |
||||
|
||||
e := db.GetEngine(ctx) |
||||
|
||||
task := &ActionTask{} |
||||
if has, err := e.ID(state.Id).Get(task); err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, util.ErrNotExist |
||||
} |
||||
|
||||
if state.Result != runnerv1.Result_RESULT_UNSPECIFIED { |
||||
task.Status = Status(state.Result) |
||||
task.Stopped = timeutil.TimeStamp(state.StoppedAt.AsTime().Unix()) |
||||
if _, err := UpdateRunJob(ctx, &ActionRunJob{ |
||||
ID: task.JobID, |
||||
Status: task.Status, |
||||
Stopped: task.Stopped, |
||||
}, nil); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
if _, err := e.ID(task.ID).Update(task); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := task.LoadAttributes(ctx); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
for _, step := range task.Steps { |
||||
var result runnerv1.Result |
||||
if v, ok := stepStates[step.Index]; ok { |
||||
result = v.Result |
||||
step.LogIndex = v.LogIndex |
||||
step.LogLength = v.LogLength |
||||
step.Started = convertTimestamp(v.StartedAt) |
||||
step.Stopped = convertTimestamp(v.StoppedAt) |
||||
} |
||||
if result != runnerv1.Result_RESULT_UNSPECIFIED { |
||||
step.Status = Status(result) |
||||
} else if step.Started != 0 { |
||||
step.Status = StatusRunning |
||||
} |
||||
if _, err := e.ID(step.ID).Update(step); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
if err := commiter.Commit(); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return task, nil |
||||
} |
||||
|
||||
func StopTask(ctx context.Context, taskID int64, status Status) error { |
||||
if !status.IsDone() { |
||||
return fmt.Errorf("cannot stop task with status %v", status) |
||||
} |
||||
e := db.GetEngine(ctx) |
||||
|
||||
task := &ActionTask{} |
||||
if has, err := e.ID(taskID).Get(task); err != nil { |
||||
return err |
||||
} else if !has { |
||||
return util.ErrNotExist |
||||
} |
||||
if task.Status.IsDone() { |
||||
return nil |
||||
} |
||||
|
||||
now := timeutil.TimeStampNow() |
||||
task.Status = status |
||||
task.Stopped = now |
||||
if _, err := UpdateRunJob(ctx, &ActionRunJob{ |
||||
ID: task.JobID, |
||||
Status: task.Status, |
||||
Stopped: task.Stopped, |
||||
}, nil); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if _, err := e.ID(task.ID).Update(task); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err := task.LoadAttributes(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, step := range task.Steps { |
||||
if !step.Status.IsDone() { |
||||
step.Status = status |
||||
if step.Started == 0 { |
||||
step.Started = now |
||||
} |
||||
step.Stopped = now |
||||
} |
||||
if _, err := e.ID(step.ID).Update(step); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func isSubset(set, subset []string) bool { |
||||
m := make(container.Set[string], len(set)) |
||||
for _, v := range set { |
||||
m.Add(v) |
||||
} |
||||
|
||||
for _, v := range subset { |
||||
if !m.Contains(v) { |
||||
return false |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
|
||||
func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp { |
||||
if timestamp.GetSeconds() == 0 && timestamp.GetNanos() == 0 { |
||||
return timeutil.TimeStamp(0) |
||||
} |
||||
return timeutil.TimeStamp(timestamp.AsTime().Unix()) |
||||
} |
||||
|
||||
func logFileName(repoFullName string, taskID int64) string { |
||||
return fmt.Sprintf("%s/%02x/%d.log", repoFullName, taskID%256, taskID) |
||||
} |
||||
|
||||
func getTaskIDFromCache(token string) int64 { |
||||
if successfulTokenTaskCache == nil { |
||||
return 0 |
||||
} |
||||
tInterface, ok := successfulTokenTaskCache.Get(token) |
||||
if !ok { |
||||
return 0 |
||||
} |
||||
t, ok := tInterface.(int64) |
||||
if !ok { |
||||
return 0 |
||||
} |
||||
return t |
||||
} |
@ -0,0 +1,105 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/container" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
type TaskList []*ActionTask |
||||
|
||||
func (tasks TaskList) GetJobIDs() []int64 { |
||||
ids := make(container.Set[int64], len(tasks)) |
||||
for _, t := range tasks { |
||||
if t.JobID == 0 { |
||||
continue |
||||
} |
||||
ids.Add(t.JobID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (tasks TaskList) LoadJobs(ctx context.Context) error { |
||||
jobIDs := tasks.GetJobIDs() |
||||
jobs := make(map[int64]*ActionRunJob, len(jobIDs)) |
||||
if err := db.GetEngine(ctx).In("id", jobIDs).Find(&jobs); err != nil { |
||||
return err |
||||
} |
||||
for _, t := range tasks { |
||||
if t.JobID > 0 && t.Job == nil { |
||||
t.Job = jobs[t.JobID] |
||||
} |
||||
} |
||||
|
||||
// TODO: Replace with "ActionJobList(maps.Values(jobs))" once available
|
||||
var jobsList ActionJobList = make([]*ActionRunJob, 0, len(jobs)) |
||||
for _, j := range jobs { |
||||
jobsList = append(jobsList, j) |
||||
} |
||||
return jobsList.LoadAttributes(ctx, true) |
||||
} |
||||
|
||||
func (tasks TaskList) LoadAttributes(ctx context.Context) error { |
||||
return tasks.LoadJobs(ctx) |
||||
} |
||||
|
||||
type FindTaskOptions struct { |
||||
db.ListOptions |
||||
RepoID int64 |
||||
OwnerID int64 |
||||
CommitSHA string |
||||
Status Status |
||||
UpdatedBefore timeutil.TimeStamp |
||||
StartedBefore timeutil.TimeStamp |
||||
RunnerID int64 |
||||
IDOrderDesc bool |
||||
} |
||||
|
||||
func (opts FindTaskOptions) toConds() builder.Cond { |
||||
cond := builder.NewCond() |
||||
if opts.RepoID > 0 { |
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) |
||||
} |
||||
if opts.OwnerID > 0 { |
||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) |
||||
} |
||||
if opts.CommitSHA != "" { |
||||
cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA}) |
||||
} |
||||
if opts.Status > StatusUnknown { |
||||
cond = cond.And(builder.Eq{"status": opts.Status}) |
||||
} |
||||
if opts.UpdatedBefore > 0 { |
||||
cond = cond.And(builder.Lt{"updated": opts.UpdatedBefore}) |
||||
} |
||||
if opts.StartedBefore > 0 { |
||||
cond = cond.And(builder.Lt{"started": opts.StartedBefore}) |
||||
} |
||||
if opts.RunnerID > 0 { |
||||
cond = cond.And(builder.Eq{"runner_id": opts.RunnerID}) |
||||
} |
||||
return cond |
||||
} |
||||
|
||||
func FindTasks(ctx context.Context, opts FindTaskOptions) (TaskList, error) { |
||||
e := db.GetEngine(ctx).Where(opts.toConds()) |
||||
if opts.PageSize > 0 && opts.Page >= 1 { |
||||
e.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) |
||||
} |
||||
if opts.IDOrderDesc { |
||||
e.OrderBy("id DESC") |
||||
} |
||||
var tasks TaskList |
||||
return tasks, e.Find(&tasks) |
||||
} |
||||
|
||||
func CountTasks(ctx context.Context, opts FindTaskOptions) (int64, error) { |
||||
return db.GetEngine(ctx).Where(opts.toConds()).Count(new(ActionTask)) |
||||
} |
@ -0,0 +1,41 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
) |
||||
|
||||
// ActionTaskStep represents a step of ActionTask
|
||||
type ActionTaskStep struct { |
||||
ID int64 |
||||
Name string `xorm:"VARCHAR(255)"` |
||||
TaskID int64 `xorm:"index unique(task_index)"` |
||||
Index int64 `xorm:"index unique(task_index)"` |
||||
RepoID int64 `xorm:"index"` |
||||
Status Status `xorm:"index"` |
||||
LogIndex int64 |
||||
LogLength int64 |
||||
Started timeutil.TimeStamp |
||||
Stopped timeutil.TimeStamp |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
func (step *ActionTaskStep) Duration() time.Duration { |
||||
return calculateDuration(step.Started, step.Stopped, step.Status) |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionTaskStep)) |
||||
} |
||||
|
||||
func GetTaskStepsByTaskID(ctx context.Context, taskID int64) ([]*ActionTaskStep, error) { |
||||
var steps []*ActionTaskStep |
||||
return steps, db.GetEngine(ctx).Where("task_id=?", taskID).OrderBy("`index` ASC").Find(&steps) |
||||
} |
@ -0,0 +1,84 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"bytes" |
||||
"encoding/binary" |
||||
"encoding/hex" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"time" |
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
) |
||||
|
||||
func generateSaltedToken() (string, string, string, string, error) { |
||||
salt, err := util.CryptoRandomString(10) |
||||
if err != nil { |
||||
return "", "", "", "", err |
||||
} |
||||
buf, err := util.CryptoRandomBytes(20) |
||||
if err != nil { |
||||
return "", "", "", "", err |
||||
} |
||||
token := hex.EncodeToString(buf) |
||||
hash := auth_model.HashToken(token, salt) |
||||
return token, salt, hash, token[len(token)-8:], nil |
||||
} |
||||
|
||||
/* |
||||
LogIndexes is the index for mapping log line number to buffer offset. |
||||
Because it uses varint encoding, it is impossible to predict its size. |
||||
But we can make a simple estimate with an assumption that each log line has 200 byte, then: |
||||
| lines | file size | index size | |
||||
|-----------|---------------------|--------------------| |
||||
| 100 | 20 KiB(20000) | 258 B(258) | |
||||
| 1000 | 195 KiB(200000) | 2.9 KiB(2958) | |
||||
| 10000 | 1.9 MiB(2000000) | 34 KiB(34715) | |
||||
| 100000 | 19 MiB(20000000) | 386 KiB(394715) | |
||||
| 1000000 | 191 MiB(200000000) | 4.1 MiB(4323626) | |
||||
| 10000000 | 1.9 GiB(2000000000) | 47 MiB(49323626) | |
||||
| 100000000 | 19 GiB(20000000000) | 490 MiB(513424280) | |
||||
*/ |
||||
type LogIndexes []int64 |
||||
|
||||
func (indexes *LogIndexes) FromDB(b []byte) error { |
||||
reader := bytes.NewReader(b) |
||||
for { |
||||
v, err := binary.ReadVarint(reader) |
||||
if err != nil { |
||||
if errors.Is(err, io.EOF) { |
||||
return nil |
||||
} |
||||
return fmt.Errorf("binary ReadVarint: %w", err) |
||||
} |
||||
*indexes = append(*indexes, v) |
||||
} |
||||
} |
||||
|
||||
func (indexes *LogIndexes) ToDB() ([]byte, error) { |
||||
buf, i := make([]byte, binary.MaxVarintLen64*len(*indexes)), 0 |
||||
for _, v := range *indexes { |
||||
n := binary.PutVarint(buf[i:], v) |
||||
i += n |
||||
} |
||||
return buf[:i], nil |
||||
} |
||||
|
||||
var timeSince = time.Since |
||||
|
||||
func calculateDuration(started, stopped timeutil.TimeStamp, status Status) time.Duration { |
||||
if started == 0 { |
||||
return 0 |
||||
} |
||||
s := started.AsTime() |
||||
if status.IsDone() { |
||||
return stopped.AsTime().Sub(s) |
||||
} |
||||
return timeSince(s).Truncate(time.Second) |
||||
} |
@ -0,0 +1,90 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"math" |
||||
"testing" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestLogIndexes_ToDB(t *testing.T) { |
||||
tests := []struct { |
||||
indexes LogIndexes |
||||
}{ |
||||
{ |
||||
indexes: []int64{1, 2, 0, -1, -2, math.MaxInt64, math.MinInt64}, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run("", func(t *testing.T) { |
||||
got, err := tt.indexes.ToDB() |
||||
require.NoError(t, err) |
||||
|
||||
indexes := LogIndexes{} |
||||
require.NoError(t, indexes.FromDB(got)) |
||||
|
||||
assert.Equal(t, tt.indexes, indexes) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func Test_calculateDuration(t *testing.T) { |
||||
oldTimeSince := timeSince |
||||
defer func() { |
||||
timeSince = oldTimeSince |
||||
}() |
||||
|
||||
timeSince = func(t time.Time) time.Duration { |
||||
return timeutil.TimeStamp(1000).AsTime().Sub(t) |
||||
} |
||||
type args struct { |
||||
started timeutil.TimeStamp |
||||
stopped timeutil.TimeStamp |
||||
status Status |
||||
} |
||||
tests := []struct { |
||||
name string |
||||
args args |
||||
want time.Duration |
||||
}{ |
||||
{ |
||||
name: "unknown", |
||||
args: args{ |
||||
started: 0, |
||||
stopped: 0, |
||||
status: StatusUnknown, |
||||
}, |
||||
want: 0, |
||||
}, |
||||
{ |
||||
name: "running", |
||||
args: args{ |
||||
started: 500, |
||||
stopped: 0, |
||||
status: StatusRunning, |
||||
}, |
||||
want: 500 * time.Second, |
||||
}, |
||||
{ |
||||
name: "done", |
||||
args: args{ |
||||
started: 500, |
||||
stopped: 600, |
||||
status: StatusSuccess, |
||||
}, |
||||
want: 100 * time.Second, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
assert.Equalf(t, tt.want, calculateDuration(tt.args.started, tt.args.stopped, tt.args.status), "calculateDuration(%v, %v, %v)", tt.args.started, tt.args.stopped, tt.args.status) |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,357 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package dbfs |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"io" |
||||
"os" |
||||
"path/filepath" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
) |
||||
|
||||
var defaultFileBlockSize int64 = 32 * 1024 |
||||
|
||||
type File interface { |
||||
io.ReadWriteCloser |
||||
io.Seeker |
||||
} |
||||
|
||||
type file struct { |
||||
ctx context.Context |
||||
metaID int64 |
||||
fullPath string |
||||
blockSize int64 |
||||
|
||||
allowRead bool |
||||
allowWrite bool |
||||
offset int64 |
||||
} |
||||
|
||||
var _ File = (*file)(nil) |
||||
|
||||
func (f *file) readAt(fileMeta *dbfsMeta, offset int64, p []byte) (n int, err error) { |
||||
if offset >= fileMeta.FileSize { |
||||
return 0, io.EOF |
||||
} |
||||
|
||||
blobPos := int(offset % f.blockSize) |
||||
blobOffset := offset - int64(blobPos) |
||||
blobRemaining := int(f.blockSize) - blobPos |
||||
needRead := len(p) |
||||
if needRead > blobRemaining { |
||||
needRead = blobRemaining |
||||
} |
||||
if blobOffset+int64(blobPos)+int64(needRead) > fileMeta.FileSize { |
||||
needRead = int(fileMeta.FileSize - blobOffset - int64(blobPos)) |
||||
} |
||||
if needRead <= 0 { |
||||
return 0, io.EOF |
||||
} |
||||
var fileData dbfsData |
||||
ok, err := db.GetEngine(f.ctx).Where("meta_id = ? AND blob_offset = ?", f.metaID, blobOffset).Get(&fileData) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
blobData := fileData.BlobData |
||||
if !ok { |
||||
blobData = nil |
||||
} |
||||
|
||||
canCopy := len(blobData) - blobPos |
||||
if canCopy <= 0 { |
||||
canCopy = 0 |
||||
} |
||||
realRead := needRead |
||||
if realRead > canCopy { |
||||
realRead = canCopy |
||||
} |
||||
if realRead > 0 { |
||||
copy(p[:realRead], fileData.BlobData[blobPos:blobPos+realRead]) |
||||
} |
||||
for i := realRead; i < needRead; i++ { |
||||
p[i] = 0 |
||||
} |
||||
return needRead, nil |
||||
} |
||||
|
||||
func (f *file) Read(p []byte) (n int, err error) { |
||||
if f.metaID == 0 || !f.allowRead { |
||||
return 0, os.ErrInvalid |
||||
} |
||||
|
||||
fileMeta, err := findFileMetaByID(f.ctx, f.metaID) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
n, err = f.readAt(fileMeta, f.offset, p) |
||||
f.offset += int64(n) |
||||
return n, err |
||||
} |
||||
|
||||
func (f *file) Write(p []byte) (n int, err error) { |
||||
if f.metaID == 0 || !f.allowWrite { |
||||
return 0, os.ErrInvalid |
||||
} |
||||
|
||||
fileMeta, err := findFileMetaByID(f.ctx, f.metaID) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
|
||||
needUpdateSize := false |
||||
written := 0 |
||||
for len(p) > 0 { |
||||
blobPos := int(f.offset % f.blockSize) |
||||
blobOffset := f.offset - int64(blobPos) |
||||
blobRemaining := int(f.blockSize) - blobPos |
||||
needWrite := len(p) |
||||
if needWrite > blobRemaining { |
||||
needWrite = blobRemaining |
||||
} |
||||
buf := make([]byte, f.blockSize) |
||||
readBytes, err := f.readAt(fileMeta, blobOffset, buf) |
||||
if err != nil && !errors.Is(err, io.EOF) { |
||||
return written, err |
||||
} |
||||
copy(buf[blobPos:blobPos+needWrite], p[:needWrite]) |
||||
if blobPos+needWrite > readBytes { |
||||
buf = buf[:blobPos+needWrite] |
||||
} else { |
||||
buf = buf[:readBytes] |
||||
} |
||||
|
||||
fileData := dbfsData{ |
||||
MetaID: fileMeta.ID, |
||||
BlobOffset: blobOffset, |
||||
BlobData: buf, |
||||
} |
||||
if res, err := db.GetEngine(f.ctx).Exec("UPDATE dbfs_data SET revision=revision+1, blob_data=? WHERE meta_id=? AND blob_offset=?", buf, fileMeta.ID, blobOffset); err != nil { |
||||
return written, err |
||||
} else if updated, err := res.RowsAffected(); err != nil { |
||||
return written, err |
||||
} else if updated == 0 { |
||||
if _, err = db.GetEngine(f.ctx).Insert(&fileData); err != nil { |
||||
return written, err |
||||
} |
||||
} |
||||
written += needWrite |
||||
f.offset += int64(needWrite) |
||||
if f.offset > fileMeta.FileSize { |
||||
fileMeta.FileSize = f.offset |
||||
needUpdateSize = true |
||||
} |
||||
p = p[needWrite:] |
||||
} |
||||
|
||||
fileMetaUpdate := dbfsMeta{ |
||||
ModifyTimestamp: timeToFileTimestamp(time.Now()), |
||||
} |
||||
if needUpdateSize { |
||||
fileMetaUpdate.FileSize = f.offset |
||||
} |
||||
if _, err := db.GetEngine(f.ctx).ID(fileMeta.ID).Update(fileMetaUpdate); err != nil { |
||||
return written, err |
||||
} |
||||
return written, nil |
||||
} |
||||
|
||||
func (f *file) Seek(n int64, whence int) (int64, error) { |
||||
if f.metaID == 0 { |
||||
return 0, os.ErrInvalid |
||||
} |
||||
|
||||
newOffset := f.offset |
||||
switch whence { |
||||
case io.SeekStart: |
||||
newOffset = n |
||||
case io.SeekCurrent: |
||||
newOffset += n |
||||
case io.SeekEnd: |
||||
size, err := f.size() |
||||
if err != nil { |
||||
return f.offset, err |
||||
} |
||||
newOffset = size + n |
||||
default: |
||||
return f.offset, os.ErrInvalid |
||||
} |
||||
if newOffset < 0 { |
||||
return f.offset, os.ErrInvalid |
||||
} |
||||
f.offset = newOffset |
||||
return newOffset, nil |
||||
} |
||||
|
||||
func (f *file) Close() error { |
||||
return nil |
||||
} |
||||
|
||||
func timeToFileTimestamp(t time.Time) int64 { |
||||
return t.UnixMicro() |
||||
} |
||||
|
||||
func (f *file) loadMetaByPath() (*dbfsMeta, error) { |
||||
var fileMeta dbfsMeta |
||||
if ok, err := db.GetEngine(f.ctx).Where("full_path = ?", f.fullPath).Get(&fileMeta); err != nil { |
||||
return nil, err |
||||
} else if ok { |
||||
f.metaID = fileMeta.ID |
||||
f.blockSize = fileMeta.BlockSize |
||||
return &fileMeta, nil |
||||
} |
||||
return nil, nil |
||||
} |
||||
|
||||
func (f *file) open(flag int) (err error) { |
||||
// see os.OpenFile for flag values
|
||||
if flag&os.O_WRONLY != 0 { |
||||
f.allowWrite = true |
||||
} else if flag&os.O_RDWR != 0 { |
||||
f.allowRead = true |
||||
f.allowWrite = true |
||||
} else /* O_RDONLY */ { |
||||
f.allowRead = true |
||||
} |
||||
|
||||
if f.allowWrite { |
||||
if flag&os.O_CREATE != 0 { |
||||
if flag&os.O_EXCL != 0 { |
||||
// file must not exist.
|
||||
if f.metaID != 0 { |
||||
return os.ErrExist |
||||
} |
||||
} else { |
||||
// create a new file if none exists.
|
||||
if f.metaID == 0 { |
||||
if err = f.createEmpty(); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
} |
||||
} |
||||
if flag&os.O_TRUNC != 0 { |
||||
if err = f.truncate(); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
if flag&os.O_APPEND != 0 { |
||||
if _, err = f.Seek(0, io.SeekEnd); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// read only mode
|
||||
if f.metaID == 0 { |
||||
return os.ErrNotExist |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (f *file) createEmpty() error { |
||||
if f.metaID != 0 { |
||||
return os.ErrExist |
||||
} |
||||
now := time.Now() |
||||
_, err := db.GetEngine(f.ctx).Insert(&dbfsMeta{ |
||||
FullPath: f.fullPath, |
||||
BlockSize: f.blockSize, |
||||
CreateTimestamp: timeToFileTimestamp(now), |
||||
ModifyTimestamp: timeToFileTimestamp(now), |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if _, err = f.loadMetaByPath(); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (f *file) truncate() error { |
||||
if f.metaID == 0 { |
||||
return os.ErrNotExist |
||||
} |
||||
return db.WithTx(f.ctx, func(ctx context.Context) error { |
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE dbfs_meta SET file_size = 0 WHERE id = ?", f.metaID); err != nil { |
||||
return err |
||||
} |
||||
if _, err := db.GetEngine(ctx).Delete(&dbfsData{MetaID: f.metaID}); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
func (f *file) renameTo(newPath string) error { |
||||
if f.metaID == 0 { |
||||
return os.ErrNotExist |
||||
} |
||||
newPath = buildPath(newPath) |
||||
return db.WithTx(f.ctx, func(ctx context.Context) error { |
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE dbfs_meta SET full_path = ? WHERE id = ?", newPath, f.metaID); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
func (f *file) delete() error { |
||||
if f.metaID == 0 { |
||||
return os.ErrNotExist |
||||
} |
||||
return db.WithTx(f.ctx, func(ctx context.Context) error { |
||||
if _, err := db.GetEngine(ctx).Delete(&dbfsMeta{ID: f.metaID}); err != nil { |
||||
return err |
||||
} |
||||
if _, err := db.GetEngine(ctx).Delete(&dbfsData{MetaID: f.metaID}); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
func (f *file) size() (int64, error) { |
||||
if f.metaID == 0 { |
||||
return 0, os.ErrNotExist |
||||
} |
||||
fileMeta, err := findFileMetaByID(f.ctx, f.metaID) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
return fileMeta.FileSize, nil |
||||
} |
||||
|
||||
func findFileMetaByID(ctx context.Context, metaID int64) (*dbfsMeta, error) { |
||||
var fileMeta dbfsMeta |
||||
if ok, err := db.GetEngine(ctx).Where("id = ?", metaID).Get(&fileMeta); err != nil { |
||||
return nil, err |
||||
} else if ok { |
||||
return &fileMeta, nil |
||||
} |
||||
return nil, nil |
||||
} |
||||
|
||||
func buildPath(path string) string { |
||||
path = filepath.Clean(path) |
||||
path = strings.ReplaceAll(path, "\\", "/") |
||||
path = strings.TrimPrefix(path, "/") |
||||
return strconv.Itoa(strings.Count(path, "/")) + ":" + path |
||||
} |
||||
|
||||
func newDbFile(ctx context.Context, path string) (*file, error) { |
||||
path = buildPath(path) |
||||
f := &file{ctx: ctx, fullPath: path, blockSize: defaultFileBlockSize} |
||||
if _, err := f.loadMetaByPath(); err != nil { |
||||
return nil, err |
||||
} |
||||
return f, nil |
||||
} |
@ -0,0 +1,73 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package dbfs |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
) |
||||
|
||||
type dbfsMeta struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
FullPath string `xorm:"VARCHAR(500) UNIQUE NOT NULL"` |
||||
BlockSize int64 `xorm:"BIGINT NOT NULL"` |
||||
FileSize int64 `xorm:"BIGINT NOT NULL"` |
||||
CreateTimestamp int64 `xorm:"BIGINT NOT NULL"` |
||||
ModifyTimestamp int64 `xorm:"BIGINT NOT NULL"` |
||||
} |
||||
|
||||
type dbfsData struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
Revision int64 `xorm:"BIGINT NOT NULL"` |
||||
MetaID int64 `xorm:"BIGINT index(meta_offset) NOT NULL"` |
||||
BlobOffset int64 `xorm:"BIGINT index(meta_offset) NOT NULL"` |
||||
BlobSize int64 `xorm:"BIGINT NOT NULL"` |
||||
BlobData []byte `xorm:"BLOB NOT NULL"` |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(dbfsMeta)) |
||||
db.RegisterModel(new(dbfsData)) |
||||
} |
||||
|
||||
func OpenFile(ctx context.Context, name string, flag int) (File, error) { |
||||
f, err := newDbFile(ctx, name) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
err = f.open(flag) |
||||
if err != nil { |
||||
_ = f.Close() |
||||
return nil, err |
||||
} |
||||
return f, nil |
||||
} |
||||
|
||||
func Open(ctx context.Context, name string) (File, error) { |
||||
return OpenFile(ctx, name, os.O_RDONLY) |
||||
} |
||||
|
||||
func Create(ctx context.Context, name string) (File, error) { |
||||
return OpenFile(ctx, name, os.O_RDWR|os.O_CREATE|os.O_TRUNC) |
||||
} |
||||
|
||||
func Rename(ctx context.Context, oldPath, newPath string) error { |
||||
f, err := newDbFile(ctx, oldPath) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer f.Close() |
||||
return f.renameTo(newPath) |
||||
} |
||||
|
||||
func Remove(ctx context.Context, name string) error { |
||||
f, err := newDbFile(ctx, name) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer f.Close() |
||||
return f.delete() |
||||
} |
@ -0,0 +1,179 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package dbfs |
||||
|
||||
import ( |
||||
"bufio" |
||||
"io" |
||||
"os" |
||||
"testing" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
|
||||
_ "github.com/mattn/go-sqlite3" |
||||
) |
||||
|
||||
func changeDefaultFileBlockSize(n int64) (restore func()) { |
||||
old := defaultFileBlockSize |
||||
defaultFileBlockSize = n |
||||
return func() { |
||||
defaultFileBlockSize = old |
||||
} |
||||
} |
||||
|
||||
func TestDbfsBasic(t *testing.T) { |
||||
defer changeDefaultFileBlockSize(4)() |
||||
|
||||
// test basic write/read
|
||||
f, err := OpenFile(db.DefaultContext, "test.txt", os.O_RDWR|os.O_CREATE) |
||||
assert.NoError(t, err) |
||||
|
||||
n, err := f.Write([]byte("0123456789")) // blocks: 0123 4567 89
|
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, 10, n) |
||||
|
||||
_, err = f.Seek(0, io.SeekStart) |
||||
assert.NoError(t, err) |
||||
|
||||
buf, err := io.ReadAll(f) |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, 10, n) |
||||
assert.EqualValues(t, "0123456789", string(buf)) |
||||
|
||||
// write some new data
|
||||
_, err = f.Seek(1, io.SeekStart) |
||||
assert.NoError(t, err) |
||||
_, err = f.Write([]byte("bcdefghi")) // blocks: 0bcd efgh i9
|
||||
assert.NoError(t, err) |
||||
|
||||
// read from offset
|
||||
buf, err = io.ReadAll(f) |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "9", string(buf)) |
||||
|
||||
// read all
|
||||
_, err = f.Seek(0, io.SeekStart) |
||||
assert.NoError(t, err) |
||||
buf, err = io.ReadAll(f) |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "0bcdefghi9", string(buf)) |
||||
|
||||
// write to new size
|
||||
_, err = f.Seek(-1, io.SeekEnd) |
||||
assert.NoError(t, err) |
||||
_, err = f.Write([]byte("JKLMNOP")) // blocks: 0bcd efgh iJKL MNOP
|
||||
assert.NoError(t, err) |
||||
_, err = f.Seek(0, io.SeekStart) |
||||
assert.NoError(t, err) |
||||
buf, err = io.ReadAll(f) |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "0bcdefghiJKLMNOP", string(buf)) |
||||
|
||||
// write beyond EOF and fill with zero
|
||||
_, err = f.Seek(5, io.SeekCurrent) |
||||
assert.NoError(t, err) |
||||
_, err = f.Write([]byte("xyzu")) // blocks: 0bcd efgh iJKL MNOP 0000 0xyz u
|
||||
assert.NoError(t, err) |
||||
_, err = f.Seek(0, io.SeekStart) |
||||
assert.NoError(t, err) |
||||
buf, err = io.ReadAll(f) |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "0bcdefghiJKLMNOP\x00\x00\x00\x00\x00xyzu", string(buf)) |
||||
|
||||
// write to the block with zeros
|
||||
_, err = f.Seek(-6, io.SeekCurrent) |
||||
assert.NoError(t, err) |
||||
_, err = f.Write([]byte("ABCD")) // blocks: 0bcd efgh iJKL MNOP 000A BCDz u
|
||||
assert.NoError(t, err) |
||||
_, err = f.Seek(0, io.SeekStart) |
||||
assert.NoError(t, err) |
||||
buf, err = io.ReadAll(f) |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "0bcdefghiJKLMNOP\x00\x00\x00ABCDzu", string(buf)) |
||||
|
||||
assert.NoError(t, f.Close()) |
||||
|
||||
// test rename
|
||||
err = Rename(db.DefaultContext, "test.txt", "test2.txt") |
||||
assert.NoError(t, err) |
||||
|
||||
_, err = OpenFile(db.DefaultContext, "test.txt", os.O_RDONLY) |
||||
assert.Error(t, err) |
||||
|
||||
f, err = OpenFile(db.DefaultContext, "test2.txt", os.O_RDONLY) |
||||
assert.NoError(t, err) |
||||
assert.NoError(t, f.Close()) |
||||
|
||||
// test remove
|
||||
err = Remove(db.DefaultContext, "test2.txt") |
||||
assert.NoError(t, err) |
||||
|
||||
_, err = OpenFile(db.DefaultContext, "test2.txt", os.O_RDONLY) |
||||
assert.Error(t, err) |
||||
} |
||||
|
||||
func TestDbfsReadWrite(t *testing.T) { |
||||
defer changeDefaultFileBlockSize(4)() |
||||
|
||||
f1, err := OpenFile(db.DefaultContext, "test.log", os.O_RDWR|os.O_CREATE) |
||||
assert.NoError(t, err) |
||||
defer f1.Close() |
||||
|
||||
f2, err := OpenFile(db.DefaultContext, "test.log", os.O_RDONLY) |
||||
assert.NoError(t, err) |
||||
defer f2.Close() |
||||
|
||||
_, err = f1.Write([]byte("line 1\n")) |
||||
assert.NoError(t, err) |
||||
|
||||
f2r := bufio.NewReader(f2) |
||||
|
||||
line, err := f2r.ReadString('\n') |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "line 1\n", line) |
||||
_, err = f2r.ReadString('\n') |
||||
assert.ErrorIs(t, err, io.EOF) |
||||
|
||||
_, err = f1.Write([]byte("line 2\n")) |
||||
assert.NoError(t, err) |
||||
|
||||
line, err = f2r.ReadString('\n') |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "line 2\n", line) |
||||
_, err = f2r.ReadString('\n') |
||||
assert.ErrorIs(t, err, io.EOF) |
||||
} |
||||
|
||||
func TestDbfsSeekWrite(t *testing.T) { |
||||
defer changeDefaultFileBlockSize(4)() |
||||
|
||||
f, err := OpenFile(db.DefaultContext, "test2.log", os.O_RDWR|os.O_CREATE) |
||||
assert.NoError(t, err) |
||||
defer f.Close() |
||||
|
||||
n, err := f.Write([]byte("111")) |
||||
assert.NoError(t, err) |
||||
|
||||
_, err = f.Seek(int64(n), io.SeekStart) |
||||
assert.NoError(t, err) |
||||
|
||||
_, err = f.Write([]byte("222")) |
||||
assert.NoError(t, err) |
||||
|
||||
_, err = f.Seek(int64(n), io.SeekStart) |
||||
assert.NoError(t, err) |
||||
|
||||
_, err = f.Write([]byte("333")) |
||||
assert.NoError(t, err) |
||||
|
||||
fr, err := OpenFile(db.DefaultContext, "test2.log", os.O_RDONLY) |
||||
assert.NoError(t, err) |
||||
defer f.Close() |
||||
|
||||
buf, err := io.ReadAll(fr) |
||||
assert.NoError(t, err) |
||||
assert.EqualValues(t, "111333", string(buf)) |
||||
} |
@ -0,0 +1,23 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package dbfs |
||||
|
||||
import ( |
||||
"path/filepath" |
||||
"testing" |
||||
|
||||
"code.gitea.io/gitea/models/unittest" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
) |
||||
|
||||
func init() { |
||||
setting.SetCustomPathAndConf("", "", "") |
||||
setting.LoadForTest() |
||||
} |
||||
|
||||
func TestMain(m *testing.M) { |
||||
unittest.MainTest(m, &unittest.TestOptions{ |
||||
GiteaRootPath: filepath.Join("..", ".."), |
||||
}) |
||||
} |
@ -0,0 +1,176 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_19 //nolint
|
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"xorm.io/xorm" |
||||
) |
||||
|
||||
func AddActionsTables(x *xorm.Engine) error { |
||||
type ActionRunner struct { |
||||
ID int64 |
||||
UUID string `xorm:"CHAR(36) UNIQUE"` |
||||
Name string `xorm:"VARCHAR(255)"` |
||||
OwnerID int64 `xorm:"index"` // org level runner, 0 means system
|
||||
RepoID int64 `xorm:"index"` // repo level runner, if orgid also is zero, then it's a global
|
||||
Description string `xorm:"TEXT"` |
||||
Base int // 0 native 1 docker 2 virtual machine
|
||||
RepoRange string // glob match which repositories could use this runner
|
||||
|
||||
Token string `xorm:"-"` |
||||
TokenHash string `xorm:"UNIQUE"` // sha256 of token
|
||||
TokenSalt string |
||||
// TokenLastEight string `xorm:"token_last_eight"` // it's unnecessary because we don't find runners by token
|
||||
|
||||
LastOnline timeutil.TimeStamp `xorm:"index"` |
||||
LastActive timeutil.TimeStamp `xorm:"index"` |
||||
|
||||
// Store OS and Artch.
|
||||
AgentLabels []string |
||||
// Store custom labes use defined.
|
||||
CustomLabels []string |
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
Deleted timeutil.TimeStamp `xorm:"deleted"` |
||||
} |
||||
|
||||
type ActionRunnerToken struct { |
||||
ID int64 |
||||
Token string `xorm:"UNIQUE"` |
||||
OwnerID int64 `xorm:"index"` // org level runner, 0 means system
|
||||
RepoID int64 `xorm:"index"` // repo level runner, if orgid also is zero, then it's a global
|
||||
IsActive bool |
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
Deleted timeutil.TimeStamp `xorm:"deleted"` |
||||
} |
||||
|
||||
type ActionRun struct { |
||||
ID int64 |
||||
Title string |
||||
RepoID int64 `xorm:"index unique(repo_index)"` |
||||
OwnerID int64 `xorm:"index"` |
||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
|
||||
TriggerUserID int64 |
||||
Ref string |
||||
CommitSHA string |
||||
Event string |
||||
IsForkPullRequest bool |
||||
EventPayload string `xorm:"LONGTEXT"` |
||||
Status int `xorm:"index"` |
||||
Started timeutil.TimeStamp |
||||
Stopped timeutil.TimeStamp |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
type ActionRunJob struct { |
||||
ID int64 |
||||
RunID int64 `xorm:"index"` |
||||
RepoID int64 `xorm:"index"` |
||||
OwnerID int64 `xorm:"index"` |
||||
CommitSHA string `xorm:"index"` |
||||
IsForkPullRequest bool |
||||
Name string `xorm:"VARCHAR(255)"` |
||||
Attempt int64 |
||||
WorkflowPayload []byte |
||||
JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id
|
||||
Needs []string `xorm:"JSON TEXT"` |
||||
RunsOn []string `xorm:"JSON TEXT"` |
||||
TaskID int64 // the latest task of the job
|
||||
Status int `xorm:"index"` |
||||
Started timeutil.TimeStamp |
||||
Stopped timeutil.TimeStamp |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated index"` |
||||
} |
||||
|
||||
type Repository struct { |
||||
NumActionRuns int `xorm:"NOT NULL DEFAULT 0"` |
||||
NumClosedActionRuns int `xorm:"NOT NULL DEFAULT 0"` |
||||
} |
||||
|
||||
type ActionRunIndex db.ResourceIndex |
||||
|
||||
type ActionTask struct { |
||||
ID int64 |
||||
JobID int64 |
||||
Attempt int64 |
||||
RunnerID int64 `xorm:"index"` |
||||
Status int `xorm:"index"` |
||||
Started timeutil.TimeStamp `xorm:"index"` |
||||
Stopped timeutil.TimeStamp |
||||
|
||||
RepoID int64 `xorm:"index"` |
||||
OwnerID int64 `xorm:"index"` |
||||
CommitSHA string `xorm:"index"` |
||||
IsForkPullRequest bool |
||||
|
||||
TokenHash string `xorm:"UNIQUE"` // sha256 of token
|
||||
TokenSalt string |
||||
TokenLastEight string `xorm:"index token_last_eight"` |
||||
|
||||
LogFilename string // file name of log
|
||||
LogInStorage bool // read log from database or from storage
|
||||
LogLength int64 // lines count
|
||||
LogSize int64 // blob size
|
||||
LogIndexes []int64 `xorm:"LONGBLOB"` // line number to offset
|
||||
LogExpired bool // files that are too old will be deleted
|
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated index"` |
||||
} |
||||
|
||||
type ActionTaskStep struct { |
||||
ID int64 |
||||
Name string `xorm:"VARCHAR(255)"` |
||||
TaskID int64 `xorm:"index unique(task_index)"` |
||||
Index int64 `xorm:"index unique(task_index)"` |
||||
RepoID int64 `xorm:"index"` |
||||
Status int `xorm:"index"` |
||||
LogIndex int64 |
||||
LogLength int64 |
||||
Started timeutil.TimeStamp |
||||
Stopped timeutil.TimeStamp |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
type dbfsMeta struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
FullPath string `xorm:"VARCHAR(500) UNIQUE NOT NULL"` |
||||
BlockSize int64 `xorm:"BIGINT NOT NULL"` |
||||
FileSize int64 `xorm:"BIGINT NOT NULL"` |
||||
CreateTimestamp int64 `xorm:"BIGINT NOT NULL"` |
||||
ModifyTimestamp int64 `xorm:"BIGINT NOT NULL"` |
||||
} |
||||
|
||||
type dbfsData struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
Revision int64 `xorm:"BIGINT NOT NULL"` |
||||
MetaID int64 `xorm:"BIGINT index(meta_offset) NOT NULL"` |
||||
BlobOffset int64 `xorm:"BIGINT index(meta_offset) NOT NULL"` |
||||
BlobSize int64 `xorm:"BIGINT NOT NULL"` |
||||
BlobData []byte `xorm:"BLOB NOT NULL"` |
||||
} |
||||
|
||||
return x.Sync( |
||||
new(ActionRunner), |
||||
new(ActionRunnerToken), |
||||
new(ActionRun), |
||||
new(ActionRunJob), |
||||
new(Repository), |
||||
new(ActionRunIndex), |
||||
new(ActionTask), |
||||
new(ActionTaskStep), |
||||
new(dbfsMeta), |
||||
new(dbfsData), |
||||
) |
||||
} |
@ -0,0 +1,64 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user |
||||
|
||||
import ( |
||||
"strings" |
||||
|
||||
"code.gitea.io/gitea/modules/structs" |
||||
) |
||||
|
||||
// NewGhostUser creates and returns a fake user for someone has deleted their account.
|
||||
func NewGhostUser() *User { |
||||
return &User{ |
||||
ID: -1, |
||||
Name: "Ghost", |
||||
LowerName: "ghost", |
||||
} |
||||
} |
||||
|
||||
// IsGhost check if user is fake user for a deleted account
|
||||
func (u *User) IsGhost() bool { |
||||
if u == nil { |
||||
return false |
||||
} |
||||
return u.ID == -1 && u.Name == "Ghost" |
||||
} |
||||
|
||||
// NewReplaceUser creates and returns a fake user for external user
|
||||
func NewReplaceUser(name string) *User { |
||||
return &User{ |
||||
ID: -1, |
||||
Name: name, |
||||
LowerName: strings.ToLower(name), |
||||
} |
||||
} |
||||
|
||||
const ( |
||||
ActionsUserID = -2 |
||||
ActionsUserName = "gitea-actions" |
||||
ActionsFullName = "Gitea Actions" |
||||
ActionsEmail = "teabot@gitea.io" |
||||
) |
||||
|
||||
// NewActionsUser creates and returns a fake user for running the actions.
|
||||
func NewActionsUser() *User { |
||||
return &User{ |
||||
ID: ActionsUserID, |
||||
Name: ActionsUserName, |
||||
LowerName: ActionsUserName, |
||||
IsActive: true, |
||||
FullName: ActionsFullName, |
||||
Email: ActionsEmail, |
||||
KeepEmailPrivate: true, |
||||
LoginName: ActionsUserName, |
||||
Type: UserTypeIndividual, |
||||
AllowCreateOrganization: true, |
||||
Visibility: structs.VisibleTypePublic, |
||||
} |
||||
} |
||||
|
||||
func (u *User) IsActions() bool { |
||||
return u != nil && u.ID == ActionsUserID |
||||
} |
@ -0,0 +1,163 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"bufio" |
||||
"context" |
||||
"fmt" |
||||
"io" |
||||
"os" |
||||
"strings" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/dbfs" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/storage" |
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" |
||||
"google.golang.org/protobuf/types/known/timestamppb" |
||||
) |
||||
|
||||
const ( |
||||
MaxLineSize = 64 * 1024 |
||||
DBFSPrefix = "actions_log/" |
||||
|
||||
timeFormat = "2006-01-02T15:04:05.0000000Z07:00" |
||||
defaultBufSize = MaxLineSize |
||||
) |
||||
|
||||
func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runnerv1.LogRow) ([]int, error) { |
||||
name := DBFSPrefix + filename |
||||
f, err := dbfs.OpenFile(ctx, name, os.O_WRONLY|os.O_CREATE) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("dbfs OpenFile %q: %w", name, err) |
||||
} |
||||
defer f.Close() |
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil { |
||||
return nil, fmt.Errorf("dbfs Seek %q: %w", name, err) |
||||
} |
||||
|
||||
writer := bufio.NewWriterSize(f, defaultBufSize) |
||||
|
||||
ns := make([]int, 0, len(rows)) |
||||
for _, row := range rows { |
||||
n, err := writer.WriteString(FormatLog(row.Time.AsTime(), row.Content) + "\n") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
ns = append(ns, n) |
||||
} |
||||
|
||||
if err := writer.Flush(); err != nil { |
||||
return nil, err |
||||
} |
||||
return ns, nil |
||||
} |
||||
|
||||
func ReadLogs(ctx context.Context, inStorage bool, filename string, offset, limit int64) ([]*runnerv1.LogRow, error) { |
||||
f, err := openLogs(ctx, inStorage, filename) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer f.Close() |
||||
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil { |
||||
return nil, fmt.Errorf("file seek: %w", err) |
||||
} |
||||
|
||||
scanner := bufio.NewScanner(f) |
||||
maxLineSize := len(timeFormat) + MaxLineSize + 1 |
||||
scanner.Buffer(make([]byte, maxLineSize), maxLineSize) |
||||
|
||||
var rows []*runnerv1.LogRow |
||||
for scanner.Scan() && (int64(len(rows)) < limit || limit < 0) { |
||||
t, c, err := ParseLog(scanner.Text()) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("parse log %q: %w", scanner.Text(), err) |
||||
} |
||||
rows = append(rows, &runnerv1.LogRow{ |
||||
Time: timestamppb.New(t), |
||||
Content: c, |
||||
}) |
||||
} |
||||
|
||||
if err := scanner.Err(); err != nil { |
||||
return nil, fmt.Errorf("scan: %w", err) |
||||
} |
||||
|
||||
return rows, nil |
||||
} |
||||
|
||||
func TransferLogs(ctx context.Context, filename string) (func(), error) { |
||||
name := DBFSPrefix + filename |
||||
remove := func() { |
||||
if err := dbfs.Remove(ctx, name); err != nil { |
||||
log.Warn("dbfs remove %q: %v", name, err) |
||||
} |
||||
} |
||||
f, err := dbfs.Open(ctx, name) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("dbfs open %q: %w", name, err) |
||||
} |
||||
defer f.Close() |
||||
|
||||
if _, err := storage.Actions.Save(filename, f, -1); err != nil { |
||||
return nil, fmt.Errorf("storage save %q: %w", filename, err) |
||||
} |
||||
return remove, nil |
||||
} |
||||
|
||||
func RemoveLogs(ctx context.Context, inStorage bool, filename string) error { |
||||
if !inStorage { |
||||
name := DBFSPrefix + filename |
||||
err := dbfs.Remove(ctx, name) |
||||
if err != nil { |
||||
return fmt.Errorf("dbfs remove %q: %w", name, err) |
||||
} |
||||
return nil |
||||
} |
||||
err := storage.Actions.Delete(filename) |
||||
if err != nil { |
||||
return fmt.Errorf("storage delete %q: %w", filename, err) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func openLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeekCloser, error) { |
||||
if !inStorage { |
||||
name := DBFSPrefix + filename |
||||
f, err := dbfs.Open(ctx, name) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("dbfs open %q: %w", name, err) |
||||
} |
||||
return f, nil |
||||
} |
||||
f, err := storage.Actions.Open(filename) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("storage open %q: %w", filename, err) |
||||
} |
||||
return f, nil |
||||
} |
||||
|
||||
func FormatLog(timestamp time.Time, content string) string { |
||||
// Content shouldn't contain new line, it will break log indexes, other control chars are safe.
|
||||
content = strings.ReplaceAll(content, "\n", `\n`) |
||||
if len(content) > MaxLineSize { |
||||
content = content[:MaxLineSize] |
||||
} |
||||
return fmt.Sprintf("%s %s", timestamp.UTC().Format(timeFormat), content) |
||||
} |
||||
|
||||
func ParseLog(in string) (time.Time, string, error) { |
||||
index := strings.IndexRune(in, ' ') |
||||
if index < 0 { |
||||
return time.Time{}, "", fmt.Errorf("invalid log: %q", in) |
||||
} |
||||
timestamp, err := time.Parse(timeFormat, in[:index]) |
||||
if err != nil { |
||||
return time.Time{}, "", err |
||||
} |
||||
return timestamp, in[index+1:], nil |
||||
} |
@ -0,0 +1,101 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
) |
||||
|
||||
const ( |
||||
preStepName = "Set up job" |
||||
postStepName = "Complete job" |
||||
) |
||||
|
||||
// FullSteps returns steps with "Set up job" and "Complete job"
|
||||
func FullSteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep { |
||||
if len(task.Steps) == 0 { |
||||
return fullStepsOfEmptySteps(task) |
||||
} |
||||
|
||||
firstStep := task.Steps[0] |
||||
var logIndex int64 |
||||
|
||||
preStep := &actions_model.ActionTaskStep{ |
||||
Name: preStepName, |
||||
LogLength: task.LogLength, |
||||
Started: task.Started, |
||||
Status: actions_model.StatusRunning, |
||||
} |
||||
|
||||
if firstStep.Status.HasRun() || firstStep.Status.IsRunning() { |
||||
preStep.LogLength = firstStep.LogIndex |
||||
preStep.Stopped = firstStep.Started |
||||
preStep.Status = actions_model.StatusSuccess |
||||
} else if task.Status.IsDone() { |
||||
preStep.Stopped = task.Stopped |
||||
preStep.Status = actions_model.StatusFailure |
||||
} |
||||
logIndex += preStep.LogLength |
||||
|
||||
var lastHasRunStep *actions_model.ActionTaskStep |
||||
for _, step := range task.Steps { |
||||
if step.Status.HasRun() { |
||||
lastHasRunStep = step |
||||
} |
||||
logIndex += step.LogLength |
||||
} |
||||
if lastHasRunStep == nil { |
||||
lastHasRunStep = preStep |
||||
} |
||||
|
||||
postStep := &actions_model.ActionTaskStep{ |
||||
Name: postStepName, |
||||
Status: actions_model.StatusWaiting, |
||||
} |
||||
if task.Status.IsDone() { |
||||
postStep.LogIndex = logIndex |
||||
postStep.LogLength = task.LogLength - postStep.LogIndex |
||||
postStep.Status = task.Status |
||||
postStep.Started = lastHasRunStep.Stopped |
||||
postStep.Stopped = task.Stopped |
||||
} |
||||
ret := make([]*actions_model.ActionTaskStep, 0, len(task.Steps)+2) |
||||
ret = append(ret, preStep) |
||||
ret = append(ret, task.Steps...) |
||||
ret = append(ret, postStep) |
||||
|
||||
return ret |
||||
} |
||||
|
||||
func fullStepsOfEmptySteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep { |
||||
preStep := &actions_model.ActionTaskStep{ |
||||
Name: preStepName, |
||||
LogLength: task.LogLength, |
||||
Started: task.Started, |
||||
Stopped: task.Stopped, |
||||
Status: actions_model.StatusRunning, |
||||
} |
||||
|
||||
postStep := &actions_model.ActionTaskStep{ |
||||
Name: postStepName, |
||||
LogIndex: task.LogLength, |
||||
Started: task.Stopped, |
||||
Stopped: task.Stopped, |
||||
Status: actions_model.StatusWaiting, |
||||
} |
||||
|
||||
if task.Status.IsDone() { |
||||
preStep.Status = task.Status |
||||
if preStep.Status.IsSuccess() { |
||||
postStep.Status = actions_model.StatusSuccess |
||||
} else { |
||||
postStep.Status = actions_model.StatusCancelled |
||||
} |
||||
} |
||||
|
||||
return []*actions_model.ActionTaskStep{ |
||||
preStep, |
||||
postStep, |
||||
} |
||||
} |
@ -0,0 +1,112 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestFullSteps(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
task *actions_model.ActionTask |
||||
want []*actions_model.ActionTaskStep |
||||
}{ |
||||
{ |
||||
name: "regular", |
||||
task: &actions_model.ActionTask{ |
||||
Steps: []*actions_model.ActionTaskStep{ |
||||
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090}, |
||||
}, |
||||
Status: actions_model.StatusSuccess, |
||||
Started: 10000, |
||||
Stopped: 10100, |
||||
LogLength: 100, |
||||
}, |
||||
want: []*actions_model.ActionTaskStep{ |
||||
{Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010}, |
||||
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 10090}, |
||||
{Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100}, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "failed step", |
||||
task: &actions_model.ActionTask{ |
||||
Steps: []*actions_model.ActionTaskStep{ |
||||
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020}, |
||||
{Status: actions_model.StatusFailure, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090}, |
||||
{Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0}, |
||||
}, |
||||
Status: actions_model.StatusFailure, |
||||
Started: 10000, |
||||
Stopped: 10100, |
||||
LogLength: 100, |
||||
}, |
||||
want: []*actions_model.ActionTaskStep{ |
||||
{Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010}, |
||||
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020}, |
||||
{Status: actions_model.StatusFailure, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090}, |
||||
{Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0}, |
||||
{Name: postStepName, Status: actions_model.StatusFailure, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100}, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "first step is running", |
||||
task: &actions_model.ActionTask{ |
||||
Steps: []*actions_model.ActionTaskStep{ |
||||
{Status: actions_model.StatusRunning, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 0}, |
||||
}, |
||||
Status: actions_model.StatusRunning, |
||||
Started: 10000, |
||||
Stopped: 10100, |
||||
LogLength: 100, |
||||
}, |
||||
want: []*actions_model.ActionTaskStep{ |
||||
{Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010}, |
||||
{Status: actions_model.StatusRunning, LogIndex: 10, LogLength: 80, Started: 10010, Stopped: 0}, |
||||
{Name: postStepName, Status: actions_model.StatusWaiting, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0}, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "first step has canceled", |
||||
task: &actions_model.ActionTask{ |
||||
Steps: []*actions_model.ActionTaskStep{ |
||||
{Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0}, |
||||
}, |
||||
Status: actions_model.StatusFailure, |
||||
Started: 10000, |
||||
Stopped: 10100, |
||||
LogLength: 100, |
||||
}, |
||||
want: []*actions_model.ActionTaskStep{ |
||||
{Name: preStepName, Status: actions_model.StatusFailure, LogIndex: 0, LogLength: 100, Started: 10000, Stopped: 10100}, |
||||
{Status: actions_model.StatusCancelled, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0}, |
||||
{Name: postStepName, Status: actions_model.StatusFailure, LogIndex: 100, LogLength: 0, Started: 10100, Stopped: 10100}, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "empty steps", |
||||
task: &actions_model.ActionTask{ |
||||
Steps: []*actions_model.ActionTaskStep{}, |
||||
Status: actions_model.StatusSuccess, |
||||
Started: 10000, |
||||
Stopped: 10100, |
||||
LogLength: 100, |
||||
}, |
||||
want: []*actions_model.ActionTaskStep{ |
||||
{Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 100, Started: 10000, Stopped: 10100}, |
||||
{Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 100, LogLength: 0, Started: 10100, Stopped: 10100}, |
||||
}, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
assert.Equalf(t, tt.want, FullSteps(tt.task), "FullSteps(%v)", tt.task) |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,75 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"bytes" |
||||
"io" |
||||
"strings" |
||||
|
||||
"code.gitea.io/gitea/modules/git" |
||||
"code.gitea.io/gitea/modules/log" |
||||
webhook_module "code.gitea.io/gitea/modules/webhook" |
||||
|
||||
"github.com/nektos/act/pkg/model" |
||||
) |
||||
|
||||
func ListWorkflows(commit *git.Commit) (git.Entries, error) { |
||||
tree, err := commit.SubTree(".gitea/workflows") |
||||
if _, ok := err.(git.ErrNotExist); ok { |
||||
tree, err = commit.SubTree(".github/workflows") |
||||
} |
||||
if _, ok := err.(git.ErrNotExist); ok { |
||||
return nil, nil |
||||
} |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
entries, err := tree.ListEntriesRecursiveFast() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
ret := make(git.Entries, 0, len(entries)) |
||||
for _, entry := range entries { |
||||
if strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml") { |
||||
ret = append(ret, entry) |
||||
} |
||||
} |
||||
return ret, nil |
||||
} |
||||
|
||||
func DetectWorkflows(commit *git.Commit, event webhook_module.HookEventType) (map[string][]byte, error) { |
||||
entries, err := ListWorkflows(commit) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
workflows := make(map[string][]byte, len(entries)) |
||||
for _, entry := range entries { |
||||
f, err := entry.Blob().DataAsync() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
content, err := io.ReadAll(f) |
||||
_ = f.Close() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(content)) |
||||
if err != nil { |
||||
log.Warn("ignore invalid workflow %q: %v", entry.Name(), err) |
||||
continue |
||||
} |
||||
for _, e := range workflow.On() { |
||||
if e == event.Event() { |
||||
workflows[entry.Name()] = content |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
return workflows, nil |
||||
} |
@ -0,0 +1,29 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting |
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/modules/log" |
||||
) |
||||
|
||||
// Actions settings
|
||||
var ( |
||||
Actions = struct { |
||||
Storage // how the created logs should be stored
|
||||
Enabled bool |
||||
DefaultActionsURL string `ini:"DEFAULT_ACTIONS_URL"` |
||||
}{ |
||||
Enabled: false, |
||||
DefaultActionsURL: "https://gitea.com", |
||||
} |
||||
) |
||||
|
||||
func newActions() { |
||||
sec := Cfg.Section("actions") |
||||
if err := sec.MapTo(&Actions); err != nil { |
||||
log.Fatal("Failed to map Actions settings: %v", err) |
||||
} |
||||
|
||||
Actions.Storage = getStorage("actions_log", "", nil) |
||||
} |
@ -0,0 +1,25 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
|
||||
"code.gitea.io/gitea/modules/web" |
||||
"code.gitea.io/gitea/routers/api/actions/ping" |
||||
"code.gitea.io/gitea/routers/api/actions/runner" |
||||
) |
||||
|
||||
func Routes(_ context.Context, prefix string) *web.Route { |
||||
m := web.NewRoute() |
||||
|
||||
path, handler := ping.NewPingServiceHandler() |
||||
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP) |
||||
|
||||
path, handler = runner.NewRunnerServiceHandler() |
||||
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP) |
||||
|
||||
return m |
||||
} |
@ -0,0 +1,38 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ping |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"code.gitea.io/gitea/modules/log" |
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1" |
||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect" |
||||
"github.com/bufbuild/connect-go" |
||||
) |
||||
|
||||
func NewPingServiceHandler() (string, http.Handler) { |
||||
return pingv1connect.NewPingServiceHandler(&Service{}) |
||||
} |
||||
|
||||
var _ pingv1connect.PingServiceHandler = (*Service)(nil) |
||||
|
||||
type Service struct { |
||||
pingv1connect.UnimplementedPingServiceHandler |
||||
} |
||||
|
||||
func (s *Service) Ping( |
||||
ctx context.Context, |
||||
req *connect.Request[pingv1.PingRequest], |
||||
) (*connect.Response[pingv1.PingResponse], error) { |
||||
log.Trace("Content-Type: %s", req.Header().Get("Content-Type")) |
||||
log.Trace("User-Agent: %s", req.Header().Get("User-Agent")) |
||||
res := connect.NewResponse(&pingv1.PingResponse{ |
||||
Data: fmt.Sprintf("Hello, %s!", req.Msg.Data), |
||||
}) |
||||
return res, nil |
||||
} |
@ -0,0 +1,61 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ping |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"testing" |
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1" |
||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect" |
||||
"github.com/bufbuild/connect-go" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestService(t *testing.T) { |
||||
mux := http.NewServeMux() |
||||
mux.Handle(pingv1connect.NewPingServiceHandler( |
||||
&Service{}, |
||||
)) |
||||
MainServiceTest(t, mux) |
||||
} |
||||
|
||||
func MainServiceTest(t *testing.T, h http.Handler) { |
||||
t.Parallel() |
||||
server := httptest.NewUnstartedServer(h) |
||||
server.EnableHTTP2 = true |
||||
server.StartTLS() |
||||
defer server.Close() |
||||
|
||||
connectClient := pingv1connect.NewPingServiceClient( |
||||
server.Client(), |
||||
server.URL, |
||||
) |
||||
|
||||
grpcClient := pingv1connect.NewPingServiceClient( |
||||
server.Client(), |
||||
server.URL, |
||||
connect.WithGRPC(), |
||||
) |
||||
|
||||
grpcWebClient := pingv1connect.NewPingServiceClient( |
||||
server.Client(), |
||||
server.URL, |
||||
connect.WithGRPCWeb(), |
||||
) |
||||
|
||||
clients := []pingv1connect.PingServiceClient{connectClient, grpcClient, grpcWebClient} |
||||
t.Run("ping request", func(t *testing.T) { |
||||
for _, client := range clients { |
||||
result, err := client.Ping(context.Background(), connect.NewRequest(&pingv1.PingRequest{ |
||||
Data: "foobar", |
||||
})) |
||||
require.NoError(t, err) |
||||
assert.Equal(t, "Hello, foobar!", result.Msg.Data) |
||||
} |
||||
}) |
||||
} |
@ -0,0 +1,79 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/subtle" |
||||
"errors" |
||||
"strings" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
auth_model "code.gitea.io/gitea/models/auth" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
|
||||
"github.com/bufbuild/connect-go" |
||||
"google.golang.org/grpc/codes" |
||||
"google.golang.org/grpc/status" |
||||
) |
||||
|
||||
const ( |
||||
uuidHeaderKey = "x-runner-uuid" |
||||
tokenHeaderKey = "x-runner-token" |
||||
) |
||||
|
||||
var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { |
||||
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { |
||||
methodName := getMethodName(request) |
||||
if methodName == "Register" { |
||||
return unaryFunc(ctx, request) |
||||
} |
||||
uuid := request.Header().Get(uuidHeaderKey) |
||||
token := request.Header().Get(tokenHeaderKey) |
||||
runner, err := actions_model.GetRunnerByUUID(ctx, uuid) |
||||
if err != nil { |
||||
if errors.Is(err, util.ErrNotExist) { |
||||
return nil, status.Error(codes.Unauthenticated, "unregistered runner") |
||||
} |
||||
return nil, status.Error(codes.Internal, err.Error()) |
||||
} |
||||
if subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))) != 1 { |
||||
return nil, status.Error(codes.Unauthenticated, "unregistered runner") |
||||
} |
||||
|
||||
cols := []string{"last_online"} |
||||
runner.LastOnline = timeutil.TimeStampNow() |
||||
if methodName == "UpdateTask" || methodName == "UpdateLog" { |
||||
runner.LastActive = timeutil.TimeStampNow() |
||||
cols = append(cols, "last_active") |
||||
} |
||||
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil { |
||||
log.Error("can't update runner status: %v", err) |
||||
} |
||||
|
||||
ctx = context.WithValue(ctx, runnerCtxKey{}, runner) |
||||
return unaryFunc(ctx, request) |
||||
} |
||||
})) |
||||
|
||||
func getMethodName(req connect.AnyRequest) string { |
||||
splits := strings.Split(req.Spec().Procedure, "/") |
||||
if len(splits) > 0 { |
||||
return splits[len(splits)-1] |
||||
} |
||||
return "" |
||||
} |
||||
|
||||
type runnerCtxKey struct{} |
||||
|
||||
func GetRunner(ctx context.Context) *actions_model.ActionRunner { |
||||
if v := ctx.Value(runnerCtxKey{}); v != nil { |
||||
if r, ok := v.(*actions_model.ActionRunner); ok { |
||||
return r |
||||
} |
||||
} |
||||
return nil |
||||
} |
@ -0,0 +1,221 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"net/http" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/modules/actions" |
||||
"code.gitea.io/gitea/modules/json" |
||||
"code.gitea.io/gitea/modules/log" |
||||
actions_service "code.gitea.io/gitea/services/actions" |
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" |
||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" |
||||
"github.com/bufbuild/connect-go" |
||||
gouuid "github.com/google/uuid" |
||||
"google.golang.org/grpc/codes" |
||||
"google.golang.org/grpc/status" |
||||
) |
||||
|
||||
func NewRunnerServiceHandler() (string, http.Handler) { |
||||
return runnerv1connect.NewRunnerServiceHandler( |
||||
&Service{}, |
||||
connect.WithCompressMinBytes(1024), |
||||
withRunner, |
||||
) |
||||
} |
||||
|
||||
var _ runnerv1connect.RunnerServiceClient = (*Service)(nil) |
||||
|
||||
type Service struct { |
||||
runnerv1connect.UnimplementedRunnerServiceHandler |
||||
} |
||||
|
||||
// Register for new runner.
|
||||
func (s *Service) Register( |
||||
ctx context.Context, |
||||
req *connect.Request[runnerv1.RegisterRequest], |
||||
) (*connect.Response[runnerv1.RegisterResponse], error) { |
||||
if req.Msg.Token == "" || req.Msg.Name == "" { |
||||
return nil, errors.New("missing runner token, name") |
||||
} |
||||
|
||||
runnerToken, err := actions_model.GetRunnerToken(ctx, req.Msg.Token) |
||||
if err != nil { |
||||
return nil, errors.New("runner token not found") |
||||
} |
||||
|
||||
if runnerToken.IsActive { |
||||
return nil, errors.New("runner token has already activated") |
||||
} |
||||
|
||||
// create new runner
|
||||
runner := &actions_model.ActionRunner{ |
||||
UUID: gouuid.New().String(), |
||||
Name: req.Msg.Name, |
||||
OwnerID: runnerToken.OwnerID, |
||||
RepoID: runnerToken.RepoID, |
||||
AgentLabels: req.Msg.AgentLabels, |
||||
CustomLabels: req.Msg.CustomLabels, |
||||
} |
||||
if err := runner.GenerateToken(); err != nil { |
||||
return nil, errors.New("can't generate token") |
||||
} |
||||
|
||||
// create new runner
|
||||
if err := actions_model.CreateRunner(ctx, runner); err != nil { |
||||
return nil, errors.New("can't create new runner") |
||||
} |
||||
|
||||
// update token status
|
||||
runnerToken.IsActive = true |
||||
if err := actions_model.UpdateRunnerToken(ctx, runnerToken, "is_active"); err != nil { |
||||
return nil, errors.New("can't update runner token status") |
||||
} |
||||
|
||||
res := connect.NewResponse(&runnerv1.RegisterResponse{ |
||||
Runner: &runnerv1.Runner{ |
||||
Id: runner.ID, |
||||
Uuid: runner.UUID, |
||||
Token: runner.Token, |
||||
Name: runner.Name, |
||||
AgentLabels: runner.AgentLabels, |
||||
CustomLabels: runner.CustomLabels, |
||||
}, |
||||
}) |
||||
|
||||
return res, nil |
||||
} |
||||
|
||||
// FetchTask assigns a task to the runner
|
||||
func (s *Service) FetchTask( |
||||
ctx context.Context, |
||||
req *connect.Request[runnerv1.FetchTaskRequest], |
||||
) (*connect.Response[runnerv1.FetchTaskResponse], error) { |
||||
runner := GetRunner(ctx) |
||||
|
||||
var task *runnerv1.Task |
||||
if t, ok, err := pickTask(ctx, runner); err != nil { |
||||
log.Error("pick task failed: %v", err) |
||||
return nil, status.Errorf(codes.Internal, "pick task: %v", err) |
||||
} else if ok { |
||||
task = t |
||||
} |
||||
|
||||
res := connect.NewResponse(&runnerv1.FetchTaskResponse{ |
||||
Task: task, |
||||
}) |
||||
return res, nil |
||||
} |
||||
|
||||
// UpdateTask updates the task status.
|
||||
func (s *Service) UpdateTask( |
||||
ctx context.Context, |
||||
req *connect.Request[runnerv1.UpdateTaskRequest], |
||||
) (*connect.Response[runnerv1.UpdateTaskResponse], error) { |
||||
{ |
||||
// to debug strange runner behaviors, it could be removed if all problems have been solved.
|
||||
stateMsg, _ := json.Marshal(req.Msg.State) |
||||
log.Trace("update task with state: %s", stateMsg) |
||||
} |
||||
|
||||
// Get Task first
|
||||
task, err := actions_model.GetTaskByID(ctx, req.Msg.State.Id) |
||||
if err != nil { |
||||
return nil, status.Errorf(codes.Internal, "can't find the task: %v", err) |
||||
} |
||||
if task.Status.IsCancelled() { |
||||
return connect.NewResponse(&runnerv1.UpdateTaskResponse{ |
||||
State: &runnerv1.TaskState{ |
||||
Id: req.Msg.State.Id, |
||||
Result: task.Status.AsResult(), |
||||
}, |
||||
}), nil |
||||
} |
||||
|
||||
task, err = actions_model.UpdateTaskByState(ctx, req.Msg.State) |
||||
if err != nil { |
||||
return nil, status.Errorf(codes.Internal, "update task: %v", err) |
||||
} |
||||
|
||||
if err := task.LoadJob(ctx); err != nil { |
||||
return nil, status.Errorf(codes.Internal, "load job: %v", err) |
||||
} |
||||
|
||||
if err := actions_service.CreateCommitStatus(ctx, task.Job); err != nil { |
||||
log.Error("Update commit status failed: %v", err) |
||||
// go on
|
||||
} |
||||
|
||||
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { |
||||
if err := actions_service.EmitJobsIfReady(task.Job.RunID); err != nil { |
||||
log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err) |
||||
} |
||||
} |
||||
|
||||
return connect.NewResponse(&runnerv1.UpdateTaskResponse{ |
||||
State: &runnerv1.TaskState{ |
||||
Id: req.Msg.State.Id, |
||||
Result: task.Status.AsResult(), |
||||
}, |
||||
}), nil |
||||
} |
||||
|
||||
// UpdateLog uploads log of the task.
|
||||
func (s *Service) UpdateLog( |
||||
ctx context.Context, |
||||
req *connect.Request[runnerv1.UpdateLogRequest], |
||||
) (*connect.Response[runnerv1.UpdateLogResponse], error) { |
||||
res := connect.NewResponse(&runnerv1.UpdateLogResponse{}) |
||||
|
||||
task, err := actions_model.GetTaskByID(ctx, req.Msg.TaskId) |
||||
if err != nil { |
||||
return nil, status.Errorf(codes.Internal, "get task: %v", err) |
||||
} |
||||
ack := task.LogLength |
||||
|
||||
if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack { |
||||
res.Msg.AckIndex = ack |
||||
return res, nil |
||||
} |
||||
|
||||
if task.LogInStorage { |
||||
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived") |
||||
} |
||||
|
||||
rows := req.Msg.Rows[ack-req.Msg.Index:] |
||||
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows) |
||||
if err != nil { |
||||
return nil, status.Errorf(codes.Internal, "write logs: %v", err) |
||||
} |
||||
task.LogLength += int64(len(rows)) |
||||
for _, n := range ns { |
||||
task.LogIndexes = append(task.LogIndexes, task.LogSize) |
||||
task.LogSize += int64(n) |
||||
} |
||||
|
||||
res.Msg.AckIndex = task.LogLength |
||||
|
||||
var remove func() |
||||
if req.Msg.NoMore { |
||||
task.LogInStorage = true |
||||
remove, err = actions.TransferLogs(ctx, task.LogFilename) |
||||
if err != nil { |
||||
return nil, status.Errorf(codes.Internal, "transfer logs: %v", err) |
||||
} |
||||
} |
||||
|
||||
if err := actions_model.UpdateTask(ctx, task, "log_indexes", "log_length", "log_size", "log_in_storage"); err != nil { |
||||
return nil, status.Errorf(codes.Internal, "update task: %v", err) |
||||
} |
||||
if remove != nil { |
||||
remove() |
||||
} |
||||
|
||||
return res, nil |
||||
} |
@ -0,0 +1,122 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
secret_model "code.gitea.io/gitea/models/secret" |
||||
"code.gitea.io/gitea/modules/json" |
||||
"code.gitea.io/gitea/modules/log" |
||||
secret_module "code.gitea.io/gitea/modules/secret" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" |
||||
"google.golang.org/protobuf/types/known/structpb" |
||||
) |
||||
|
||||
func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) { |
||||
t, ok, err := actions_model.CreateTaskForRunner(ctx, runner) |
||||
if err != nil { |
||||
return nil, false, fmt.Errorf("CreateTaskForRunner: %w", err) |
||||
} |
||||
if !ok { |
||||
return nil, false, nil |
||||
} |
||||
|
||||
task := &runnerv1.Task{ |
||||
Id: t.ID, |
||||
WorkflowPayload: t.Job.WorkflowPayload, |
||||
Context: generateTaskContext(t), |
||||
Secrets: getSecretsOfTask(ctx, t), |
||||
} |
||||
return task, true, nil |
||||
} |
||||
|
||||
func getSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) map[string]string { |
||||
secrets := map[string]string{} |
||||
if task.Job.Run.IsForkPullRequest { |
||||
// ignore secrets for fork pull request
|
||||
return secrets |
||||
} |
||||
|
||||
ownerSecrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{OwnerID: task.Job.Run.Repo.OwnerID}) |
||||
if err != nil { |
||||
log.Error("find secrets of owner %v: %v", task.Job.Run.Repo.OwnerID, err) |
||||
// go on
|
||||
} |
||||
repoSecrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{RepoID: task.Job.Run.RepoID}) |
||||
if err != nil { |
||||
log.Error("find secrets of repo %v: %v", task.Job.Run.RepoID, err) |
||||
// go on
|
||||
} |
||||
|
||||
for _, secret := range append(ownerSecrets, repoSecrets...) { |
||||
if v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data); err != nil { |
||||
log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err) |
||||
// go on
|
||||
} else { |
||||
secrets[secret.Name] = v |
||||
} |
||||
} |
||||
|
||||
if _, ok := secrets["GITHUB_TOKEN"]; !ok { |
||||
secrets["GITHUB_TOKEN"] = task.Token |
||||
} |
||||
if _, ok := secrets["GITEA_TOKEN"]; !ok { |
||||
secrets["GITEA_TOKEN"] = task.Token |
||||
} |
||||
|
||||
return secrets |
||||
} |
||||
|
||||
func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { |
||||
event := map[string]interface{}{} |
||||
_ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event) |
||||
|
||||
taskContext, _ := structpb.NewStruct(map[string]interface{}{ |
||||
// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
"action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2.
|
||||
"action_path": "", // string, The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action.
|
||||
"action_ref": "", // string, For a step executing an action, this is the ref of the action being executed. For example, v2.
|
||||
"action_repository": "", // string, For a step executing an action, this is the owner and repository name of the action. For example, actions/checkout.
|
||||
"action_status": "", // string, For a composite action, the current result of the composite action.
|
||||
"actor": t.Job.Run.TriggerUser.Name, // string, The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from github.triggering_actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges.
|
||||
"api_url": "", // string, The URL of the GitHub REST API.
|
||||
"base_ref": "", // string, The base_ref or target branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target.
|
||||
"env": "", // string, Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions."
|
||||
"event": event, // object, The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in "Events that trigger workflows." For example, for a workflow run triggered by the push event, this object contains the contents of the push webhook payload.
|
||||
"event_name": t.Job.Run.Event.Event(), // string, The name of the event that triggered the workflow run.
|
||||
"event_path": "", // string, The path to the file on the runner that contains the full event webhook payload.
|
||||
"graphql_url": "", // string, The URL of the GitHub GraphQL API.
|
||||
"head_ref": "", // string, The head_ref or source branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target.
|
||||
"job": fmt.Sprint(t.JobID), // string, The job_id of the current job.
|
||||
"ref": t.Job.Run.Ref, // string, The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by push, this is the branch or tag ref that was pushed. For workflows triggered by pull_request, this is the pull request merge branch. For workflows triggered by release, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is refs/heads/<branch_name>, for pull requests it is refs/pull/<pr_number>/merge, and for tags it is refs/tags/<tag_name>. For example, refs/heads/feature-branch-1.
|
||||
"ref_name": t.Job.Run.Ref, // string, The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, feature-branch-1.
|
||||
"ref_protected": false, // boolean, true if branch protections are configured for the ref that triggered the workflow run.
|
||||
"ref_type": "", // string, The type of ref that triggered the workflow run. Valid values are branch or tag.
|
||||
"path": "", // string, Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions."
|
||||
"repository": t.Job.Run.Repo.OwnerName + "/" + t.Job.Run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World.
|
||||
"repository_owner": t.Job.Run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
|
||||
"repositoryUrl": t.Job.Run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
|
||||
"retention_days": "", // string, The number of days that workflow run logs and artifacts are kept.
|
||||
"run_id": fmt.Sprint(t.Job.RunID), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
|
||||
"run_number": fmt.Sprint(t.Job.Run.Index), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
|
||||
"run_attempt": fmt.Sprint(t.Job.Attempt), // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
|
||||
"secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces.
|
||||
"server_url": setting.AppURL, // string, The URL of the GitHub server. For example: https://github.com.
|
||||
"sha": t.Job.Run.CommitSHA, // string, The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see "Events that trigger workflows." For example, ffac537e6cbbf934b08745a378932722df287a53.
|
||||
"token": t.Token, // string, A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the GITHUB_TOKEN secret. For more information, see "Automatic token authentication."
|
||||
"triggering_actor": "", // string, The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from github.actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges.
|
||||
"workflow": t.Job.Run.WorkflowID, // string, The name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository.
|
||||
"workspace": "", // string, The default working directory on the runner for steps, and the default location of your repository when using the checkout action.
|
||||
|
||||
// additional contexts
|
||||
"gitea_default_actions_url": setting.Actions.DefaultActionsURL, |
||||
}) |
||||
|
||||
return taskContext |
||||
} |
@ -0,0 +1,78 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin |
||||
|
||||
import ( |
||||
"net/url" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/base" |
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
actions_shared "code.gitea.io/gitea/routers/web/shared/actions" |
||||
) |
||||
|
||||
const ( |
||||
tplRunners base.TplName = "admin/runners/base" |
||||
tplRunnerEdit base.TplName = "admin/runners/edit" |
||||
) |
||||
|
||||
// Runners show all the runners
|
||||
func Runners(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.runners") |
||||
ctx.Data["PageIsAdmin"] = true |
||||
ctx.Data["PageIsAdminRunners"] = true |
||||
|
||||
page := ctx.FormInt("page") |
||||
if page <= 1 { |
||||
page = 1 |
||||
} |
||||
|
||||
opts := actions_model.FindRunnerOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: page, |
||||
PageSize: 100, |
||||
}, |
||||
Sort: ctx.Req.URL.Query().Get("sort"), |
||||
Filter: ctx.Req.URL.Query().Get("q"), |
||||
} |
||||
|
||||
actions_shared.RunnersList(ctx, tplRunners, opts) |
||||
} |
||||
|
||||
// EditRunner show editing runner page
|
||||
func EditRunner(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner") |
||||
ctx.Data["PageIsAdmin"] = true |
||||
ctx.Data["PageIsAdminRunners"] = true |
||||
|
||||
page := ctx.FormInt("page") |
||||
if page <= 1 { |
||||
page = 1 |
||||
} |
||||
|
||||
actions_shared.RunnerDetails(ctx, tplRunnerEdit, page, ctx.ParamsInt64(":runnerid"), 0, 0) |
||||
} |
||||
|
||||
// EditRunnerPost response for editing runner
|
||||
func EditRunnerPost(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.runners.edit") |
||||
ctx.Data["PageIsAdmin"] = true |
||||
ctx.Data["PageIsAdminRunners"] = true |
||||
actions_shared.RunnerDetailsEditPost(ctx, ctx.ParamsInt64(":runnerid"), 0, 0, |
||||
setting.AppSubURL+"/admin/runners/"+url.PathEscape(ctx.Params(":runnerid"))) |
||||
} |
||||
|
||||
// DeleteRunnerPost response for deleting a runner
|
||||
func DeleteRunnerPost(ctx *context.Context) { |
||||
actions_shared.RunnerDeletePost(ctx, ctx.ParamsInt64(":runnerid"), |
||||
setting.AppSubURL+"/admin/runners/", |
||||
setting.AppSubURL+"/admin/runners/"+url.PathEscape(ctx.Params(":runnerid")), |
||||
) |
||||
} |
||||
|
||||
func ResetRunnerRegistrationToken(ctx *context.Context) { |
||||
actions_shared.RunnerResetRegistrationToken(ctx, 0, 0, setting.AppSubURL+"/admin/runners/") |
||||
} |
@ -0,0 +1,78 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org |
||||
|
||||
import ( |
||||
"net/url" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/context" |
||||
actions_shared "code.gitea.io/gitea/routers/web/shared/actions" |
||||
) |
||||
|
||||
// Runners render runners page
|
||||
func Runners(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("org.runners") |
||||
ctx.Data["PageIsOrgSettings"] = true |
||||
ctx.Data["PageIsOrgSettingsRunners"] = true |
||||
|
||||
page := ctx.FormInt("page") |
||||
if page <= 1 { |
||||
page = 1 |
||||
} |
||||
|
||||
opts := actions_model.FindRunnerOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: page, |
||||
PageSize: 100, |
||||
}, |
||||
Sort: ctx.Req.URL.Query().Get("sort"), |
||||
Filter: ctx.Req.URL.Query().Get("q"), |
||||
OwnerID: ctx.Org.Organization.ID, |
||||
WithAvailable: true, |
||||
} |
||||
|
||||
actions_shared.RunnersList(ctx, tplSettingsRunners, opts) |
||||
} |
||||
|
||||
// ResetRunnerRegistrationToken reset runner registration token
|
||||
func ResetRunnerRegistrationToken(ctx *context.Context) { |
||||
actions_shared.RunnerResetRegistrationToken(ctx, |
||||
ctx.Org.Organization.ID, 0, |
||||
ctx.Org.OrgLink+"/settings/runners") |
||||
} |
||||
|
||||
// RunnersEdit render runner edit page
|
||||
func RunnersEdit(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("org.runners.edit") |
||||
ctx.Data["PageIsOrgSettings"] = true |
||||
ctx.Data["PageIsOrgSettingsRunners"] = true |
||||
page := ctx.FormInt("page") |
||||
if page <= 1 { |
||||
page = 1 |
||||
} |
||||
|
||||
actions_shared.RunnerDetails(ctx, tplSettingsRunnersEdit, page, |
||||
ctx.ParamsInt64(":runnerid"), ctx.Org.Organization.ID, 0, |
||||
) |
||||
} |
||||
|
||||
// RunnersEditPost response for editing runner
|
||||
func RunnersEditPost(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("org.runners.edit") |
||||
ctx.Data["PageIsOrgSettings"] = true |
||||
ctx.Data["PageIsOrgSettingsRunners"] = true |
||||
actions_shared.RunnerDetailsEditPost(ctx, ctx.ParamsInt64(":runnerid"), |
||||
ctx.Org.Organization.ID, 0, |
||||
ctx.Org.OrgLink+"/settings/runners/"+url.PathEscape(ctx.Params(":runnerid"))) |
||||
} |
||||
|
||||
// RunnerDeletePost response for deleting runner
|
||||
func RunnerDeletePost(ctx *context.Context) { |
||||
actions_shared.RunnerDeletePost(ctx, |
||||
ctx.ParamsInt64(":runnerid"), |
||||
ctx.Org.OrgLink+"/settings/runners", |
||||
ctx.Org.OrgLink+"/settings/runners/"+url.PathEscape(ctx.Params(":runnerid"))) |
||||
} |
@ -0,0 +1,139 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"net/http" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/models/unit" |
||||
"code.gitea.io/gitea/modules/actions" |
||||
"code.gitea.io/gitea/modules/base" |
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/git" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/services/convert" |
||||
) |
||||
|
||||
const ( |
||||
tplListActions base.TplName = "repo/actions/list" |
||||
tplViewActions base.TplName = "repo/actions/view" |
||||
) |
||||
|
||||
// MustEnableActions check if actions are enabled in settings
|
||||
func MustEnableActions(ctx *context.Context) { |
||||
if !setting.Actions.Enabled { |
||||
ctx.NotFound("MustEnableActions", nil) |
||||
return |
||||
} |
||||
|
||||
if unit.TypeActions.UnitGlobalDisabled() { |
||||
ctx.NotFound("MustEnableActions", nil) |
||||
return |
||||
} |
||||
|
||||
if ctx.Repo.Repository != nil { |
||||
if !ctx.Repo.CanRead(unit.TypeActions) { |
||||
ctx.NotFound("MustEnableActions", nil) |
||||
return |
||||
} |
||||
} |
||||
} |
||||
|
||||
func List(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.actions") |
||||
ctx.Data["PageIsActions"] = true |
||||
|
||||
var workflows git.Entries |
||||
if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} else if !empty { |
||||
defaultBranch, err := ctx.Repo.GitRepo.GetDefaultBranch() |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
commit, err := ctx.Repo.GitRepo.GetBranchCommit(defaultBranch) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
workflows, err = actions.ListWorkflows(commit) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
} |
||||
|
||||
ctx.Data["workflows"] = workflows |
||||
ctx.Data["RepoLink"] = ctx.Repo.Repository.HTMLURL() |
||||
|
||||
page := ctx.FormInt("page") |
||||
if page <= 0 { |
||||
page = 1 |
||||
} |
||||
|
||||
workflow := ctx.FormString("workflow") |
||||
ctx.Data["CurWorkflow"] = workflow |
||||
|
||||
opts := actions_model.FindRunOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: page, |
||||
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), |
||||
}, |
||||
RepoID: ctx.Repo.Repository.ID, |
||||
WorkflowFileName: workflow, |
||||
} |
||||
|
||||
// open counts
|
||||
opts.IsClosed = util.OptionalBoolFalse |
||||
numOpenRuns, err := actions_model.CountRuns(ctx, opts) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
ctx.Data["NumOpenActionRuns"] = numOpenRuns |
||||
|
||||
// closed counts
|
||||
opts.IsClosed = util.OptionalBoolTrue |
||||
numClosedRuns, err := actions_model.CountRuns(ctx, opts) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
ctx.Data["NumClosedActionRuns"] = numClosedRuns |
||||
|
||||
opts.IsClosed = util.OptionalBoolNone |
||||
if ctx.FormString("state") == "closed" { |
||||
opts.IsClosed = util.OptionalBoolTrue |
||||
ctx.Data["IsShowClosed"] = true |
||||
} else { |
||||
opts.IsClosed = util.OptionalBoolFalse |
||||
} |
||||
runs, total, err := actions_model.FindRuns(ctx, opts) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
|
||||
for _, run := range runs { |
||||
run.Repo = ctx.Repo.Repository |
||||
} |
||||
|
||||
if err := runs.LoadTriggerUser(ctx); err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
|
||||
ctx.Data["Runs"] = runs |
||||
|
||||
pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) |
||||
pager.SetDefaultParams(ctx) |
||||
ctx.Data["Page"] = pager |
||||
|
||||
ctx.HTML(http.StatusOK, tplListActions) |
||||
} |
@ -0,0 +1,297 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
"time" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/models/unit" |
||||
"code.gitea.io/gitea/modules/actions" |
||||
context_module "code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/modules/web" |
||||
actions_service "code.gitea.io/gitea/services/actions" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
func View(ctx *context_module.Context) { |
||||
ctx.Data["PageIsActions"] = true |
||||
runIndex := ctx.ParamsInt64("run") |
||||
jobIndex := ctx.ParamsInt64("job") |
||||
ctx.Data["RunIndex"] = runIndex |
||||
ctx.Data["JobIndex"] = jobIndex |
||||
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" |
||||
|
||||
if getRunJobs(ctx, runIndex, jobIndex); ctx.Written() { |
||||
return |
||||
} |
||||
|
||||
ctx.HTML(http.StatusOK, tplViewActions) |
||||
} |
||||
|
||||
type ViewRequest struct { |
||||
LogCursors []struct { |
||||
Step int `json:"step"` |
||||
Cursor int64 `json:"cursor"` |
||||
Expanded bool `json:"expanded"` |
||||
} `json:"logCursors"` |
||||
} |
||||
|
||||
type ViewResponse struct { |
||||
State struct { |
||||
Run struct { |
||||
HTMLURL string `json:"htmlurl"` |
||||
Title string `json:"title"` |
||||
CanCancel bool `json:"canCancel"` |
||||
Done bool `json:"done"` |
||||
Jobs []*ViewJob `json:"jobs"` |
||||
} `json:"run"` |
||||
CurrentJob struct { |
||||
Title string `json:"title"` |
||||
Detail string `json:"detail"` |
||||
Steps []*ViewJobStep `json:"steps"` |
||||
} `json:"currentJob"` |
||||
} `json:"state"` |
||||
Logs struct { |
||||
StepsLog []*ViewStepLog `json:"stepsLog"` |
||||
} `json:"logs"` |
||||
} |
||||
|
||||
type ViewJob struct { |
||||
ID int64 `json:"id"` |
||||
Name string `json:"name"` |
||||
Status string `json:"status"` |
||||
CanRerun bool `json:"canRerun"` |
||||
} |
||||
|
||||
type ViewJobStep struct { |
||||
Summary string `json:"summary"` |
||||
Duration string `json:"duration"` |
||||
Status string `json:"status"` |
||||
} |
||||
|
||||
type ViewStepLog struct { |
||||
Step int `json:"step"` |
||||
Cursor int64 `json:"cursor"` |
||||
Lines []*ViewStepLogLine `json:"lines"` |
||||
} |
||||
|
||||
type ViewStepLogLine struct { |
||||
Index int64 `json:"index"` |
||||
Message string `json:"message"` |
||||
Timestamp float64 `json:"timestamp"` |
||||
} |
||||
|
||||
func ViewPost(ctx *context_module.Context) { |
||||
req := web.GetForm(ctx).(*ViewRequest) |
||||
runIndex := ctx.ParamsInt64("run") |
||||
jobIndex := ctx.ParamsInt64("job") |
||||
|
||||
current, jobs := getRunJobs(ctx, runIndex, jobIndex) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
run := current.Run |
||||
|
||||
resp := &ViewResponse{} |
||||
|
||||
resp.State.Run.Title = run.Title |
||||
resp.State.Run.HTMLURL = run.HTMLURL() |
||||
resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) |
||||
resp.State.Run.Done = run.Status.IsDone() |
||||
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
|
||||
for _, v := range jobs { |
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ |
||||
ID: v.ID, |
||||
Name: v.Name, |
||||
Status: v.Status.String(), |
||||
CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions), |
||||
}) |
||||
} |
||||
|
||||
var task *actions_model.ActionTask |
||||
if current.TaskID > 0 { |
||||
var err error |
||||
task, err = actions_model.GetTaskByID(ctx, current.TaskID) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
task.Job = current |
||||
if err := task.LoadAttributes(ctx); err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
} |
||||
|
||||
resp.State.CurrentJob.Title = current.Name |
||||
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) |
||||
resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
|
||||
resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json
|
||||
if task != nil { |
||||
steps := actions.FullSteps(task) |
||||
|
||||
for _, v := range steps { |
||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{ |
||||
Summary: v.Name, |
||||
Duration: v.Duration().String(), |
||||
Status: v.Status.String(), |
||||
}) |
||||
} |
||||
|
||||
for _, cursor := range req.LogCursors { |
||||
if !cursor.Expanded { |
||||
continue |
||||
} |
||||
|
||||
step := steps[cursor.Step] |
||||
|
||||
logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json
|
||||
if c := cursor.Cursor; c < step.LogLength && c >= 0 { |
||||
index := step.LogIndex + c |
||||
length := step.LogLength - cursor.Cursor |
||||
offset := task.LogIndexes[index] |
||||
var err error |
||||
logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
|
||||
for i, row := range logRows { |
||||
logLines = append(logLines, &ViewStepLogLine{ |
||||
Index: cursor.Cursor + int64(i) + 1, // start at 1
|
||||
Message: row.Content, |
||||
Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), |
||||
}) |
||||
} |
||||
} |
||||
|
||||
resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ |
||||
Step: cursor.Step, |
||||
Cursor: cursor.Cursor + int64(len(logLines)), |
||||
Lines: logLines, |
||||
}) |
||||
} |
||||
} |
||||
|
||||
ctx.JSON(http.StatusOK, resp) |
||||
} |
||||
|
||||
func Rerun(ctx *context_module.Context) { |
||||
runIndex := ctx.ParamsInt64("run") |
||||
jobIndex := ctx.ParamsInt64("job") |
||||
|
||||
job, _ := getRunJobs(ctx, runIndex, jobIndex) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
status := job.Status |
||||
if !status.IsDone() { |
||||
ctx.JSON(http.StatusOK, struct{}{}) |
||||
return |
||||
} |
||||
|
||||
job.TaskID = 0 |
||||
job.Status = actions_model.StatusWaiting |
||||
job.Started = 0 |
||||
job.Stopped = 0 |
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error { |
||||
if _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "task_id", "status", "started", "stopped"); err != nil { |
||||
return err |
||||
} |
||||
return actions_service.CreateCommitStatus(ctx, job) |
||||
}); err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
|
||||
ctx.JSON(http.StatusOK, struct{}{}) |
||||
} |
||||
|
||||
func Cancel(ctx *context_module.Context) { |
||||
runIndex := ctx.ParamsInt64("run") |
||||
|
||||
_, jobs := getRunJobs(ctx, runIndex, -1) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error { |
||||
for _, job := range jobs { |
||||
status := job.Status |
||||
if status.IsDone() { |
||||
continue |
||||
} |
||||
if job.TaskID == 0 { |
||||
job.Status = actions_model.StatusCancelled |
||||
job.Stopped = timeutil.TimeStampNow() |
||||
n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if n == 0 { |
||||
return fmt.Errorf("job has changed, try again") |
||||
} |
||||
continue |
||||
} |
||||
if err := actions_model.StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil { |
||||
return err |
||||
} |
||||
if err := actions_service.CreateCommitStatus(ctx, job); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
}); err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return |
||||
} |
||||
|
||||
ctx.JSON(http.StatusOK, struct{}{}) |
||||
} |
||||
|
||||
// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
|
||||
// Any error will be written to the ctx.
|
||||
// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.
|
||||
func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) { |
||||
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) |
||||
if err != nil { |
||||
if errors.Is(err, util.ErrNotExist) { |
||||
ctx.Error(http.StatusNotFound, err.Error()) |
||||
return nil, nil |
||||
} |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return nil, nil |
||||
} |
||||
run.Repo = ctx.Repo.Repository |
||||
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) |
||||
if err != nil { |
||||
ctx.Error(http.StatusInternalServerError, err.Error()) |
||||
return nil, nil |
||||
} |
||||
if len(jobs) == 0 { |
||||
ctx.Error(http.StatusNotFound, err.Error()) |
||||
return nil, nil |
||||
} |
||||
|
||||
for _, v := range jobs { |
||||
v.Run = run |
||||
} |
||||
|
||||
if jobIndex >= 0 && jobIndex < int64(len(jobs)) { |
||||
return jobs[jobIndex], jobs |
||||
} |
||||
return jobs[0], jobs |
||||
} |
@ -0,0 +1,76 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo |
||||
|
||||
import ( |
||||
"net/url" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/context" |
||||
actions_shared "code.gitea.io/gitea/routers/web/shared/actions" |
||||
) |
||||
|
||||
const ( |
||||
tplRunners = "repo/settings/runners" |
||||
tplRunnerEdit = "repo/settings/runner_edit" |
||||
) |
||||
|
||||
// Runners render runners page
|
||||
func Runners(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.runners") |
||||
ctx.Data["PageIsSettingsRunners"] = true |
||||
|
||||
page := ctx.FormInt("page") |
||||
if page <= 1 { |
||||
page = 1 |
||||
} |
||||
|
||||
opts := actions_model.FindRunnerOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: page, |
||||
PageSize: 100, |
||||
}, |
||||
Sort: ctx.Req.URL.Query().Get("sort"), |
||||
Filter: ctx.Req.URL.Query().Get("q"), |
||||
RepoID: ctx.Repo.Repository.ID, |
||||
WithAvailable: true, |
||||
} |
||||
|
||||
actions_shared.RunnersList(ctx, tplRunners, opts) |
||||
} |
||||
|
||||
func RunnersEdit(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.runners") |
||||
ctx.Data["PageIsSettingsRunners"] = true |
||||
page := ctx.FormInt("page") |
||||
if page <= 1 { |
||||
page = 1 |
||||
} |
||||
|
||||
actions_shared.RunnerDetails(ctx, tplRunnerEdit, page, |
||||
ctx.ParamsInt64(":runnerid"), 0, ctx.Repo.Repository.ID, |
||||
) |
||||
} |
||||
|
||||
func RunnersEditPost(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.runners") |
||||
ctx.Data["PageIsSettingsRunners"] = true |
||||
actions_shared.RunnerDetailsEditPost(ctx, ctx.ParamsInt64(":runnerid"), |
||||
0, ctx.Repo.Repository.ID, |
||||
ctx.Repo.RepoLink+"/settings/runners/"+url.PathEscape(ctx.Params(":runnerid"))) |
||||
} |
||||
|
||||
func ResetRunnerRegistrationToken(ctx *context.Context) { |
||||
actions_shared.RunnerResetRegistrationToken(ctx, |
||||
0, ctx.Repo.Repository.ID, |
||||
ctx.Repo.RepoLink+"/settings/runners") |
||||
} |
||||
|
||||
// RunnerDeletePost response for deleting runner
|
||||
func RunnerDeletePost(ctx *context.Context) { |
||||
actions_shared.RunnerDeletePost(ctx, ctx.ParamsInt64(":runnerid"), |
||||
ctx.Repo.RepoLink+"/settings/runners", |
||||
ctx.Repo.RepoLink+"/settings/runners/"+url.PathEscape(ctx.Params(":runnerid"))) |
||||
} |
@ -0,0 +1,190 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"errors" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/base" |
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/modules/web" |
||||
"code.gitea.io/gitea/services/forms" |
||||
) |
||||
|
||||
// RunnersList render common runners list page
|
||||
func RunnersList(ctx *context.Context, tplName base.TplName, opts actions_model.FindRunnerOptions) { |
||||
count, err := actions_model.CountRunners(ctx, opts) |
||||
if err != nil { |
||||
ctx.ServerError("AdminRunners", err) |
||||
return |
||||
} |
||||
|
||||
runners, err := actions_model.FindRunners(ctx, opts) |
||||
if err != nil { |
||||
ctx.ServerError("AdminRunners", err) |
||||
return |
||||
} |
||||
if err := runners.LoadAttributes(ctx); err != nil { |
||||
ctx.ServerError("LoadAttributes", err) |
||||
return |
||||
} |
||||
|
||||
// ownid=0,repo_id=0,means this token is used for global
|
||||
var token *actions_model.ActionRunnerToken |
||||
token, err = actions_model.GetUnactivatedRunnerToken(ctx, opts.OwnerID, opts.RepoID) |
||||
if errors.Is(err, util.ErrNotExist) { |
||||
token, err = actions_model.NewRunnerToken(ctx, opts.OwnerID, opts.RepoID) |
||||
if err != nil { |
||||
ctx.ServerError("CreateRunnerToken", err) |
||||
return |
||||
} |
||||
} else if err != nil { |
||||
ctx.ServerError("GetUnactivatedRunnerToken", err) |
||||
return |
||||
} |
||||
|
||||
ctx.Data["Keyword"] = opts.Filter |
||||
ctx.Data["Runners"] = runners |
||||
ctx.Data["Total"] = count |
||||
ctx.Data["RegistrationToken"] = token.Token |
||||
ctx.Data["RunnerOnwerID"] = opts.OwnerID |
||||
ctx.Data["RunnerRepoID"] = opts.RepoID |
||||
|
||||
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) |
||||
ctx.Data["Page"] = pager |
||||
|
||||
ctx.HTML(http.StatusOK, tplName) |
||||
} |
||||
|
||||
// RunnerDetails render runner details page
|
||||
func RunnerDetails(ctx *context.Context, tplName base.TplName, page int, runnerID, ownerID, repoID int64) { |
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID) |
||||
if err != nil { |
||||
ctx.ServerError("GetRunnerByID", err) |
||||
return |
||||
} |
||||
if err := runner.LoadAttributes(ctx); err != nil { |
||||
ctx.ServerError("LoadAttributes", err) |
||||
return |
||||
} |
||||
if !runner.Editable(ownerID, repoID) { |
||||
err = errors.New("no permission to edit this runner") |
||||
ctx.NotFound("RunnerDetails", err) |
||||
return |
||||
} |
||||
|
||||
ctx.Data["Runner"] = runner |
||||
|
||||
opts := actions_model.FindTaskOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: page, |
||||
PageSize: 30, |
||||
}, |
||||
Status: actions_model.StatusUnknown, // Unknown means all
|
||||
IDOrderDesc: true, |
||||
RunnerID: runner.ID, |
||||
} |
||||
|
||||
count, err := actions_model.CountTasks(ctx, opts) |
||||
if err != nil { |
||||
ctx.ServerError("CountTasks", err) |
||||
return |
||||
} |
||||
|
||||
tasks, err := actions_model.FindTasks(ctx, opts) |
||||
if err != nil { |
||||
ctx.ServerError("FindTasks", err) |
||||
return |
||||
} |
||||
if err = tasks.LoadAttributes(ctx); err != nil { |
||||
ctx.ServerError("TasksLoadAttributes", err) |
||||
return |
||||
} |
||||
|
||||
ctx.Data["Tasks"] = tasks |
||||
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) |
||||
ctx.Data["Page"] = pager |
||||
|
||||
ctx.HTML(http.StatusOK, tplName) |
||||
} |
||||
|
||||
// RunnerDetailsEditPost response for edit runner details
|
||||
func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64, redirectTo string) { |
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID) |
||||
if err != nil { |
||||
log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL) |
||||
ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err) |
||||
return |
||||
} |
||||
if !runner.Editable(ownerID, repoID) { |
||||
ctx.NotFound("RunnerDetailsEditPost.Editable", util.NewPermissionDeniedErrorf("no permission to edit this runner")) |
||||
return |
||||
} |
||||
|
||||
form := web.GetForm(ctx).(*forms.EditRunnerForm) |
||||
runner.Description = form.Description |
||||
runner.CustomLabels = splitLabels(form.CustomLabels) |
||||
|
||||
err = actions_model.UpdateRunner(ctx, runner, "description", "custom_labels") |
||||
if err != nil { |
||||
log.Warn("RunnerDetailsEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) |
||||
ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner_failed")) |
||||
ctx.Redirect(redirectTo) |
||||
return |
||||
} |
||||
|
||||
log.Debug("RunnerDetailsEditPost success: %s", ctx.Req.URL) |
||||
|
||||
ctx.Flash.Success(ctx.Tr("actions.runners.update_runner_success")) |
||||
ctx.Redirect(redirectTo) |
||||
} |
||||
|
||||
// RunnerResetRegistrationToken reset registration token
|
||||
func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, redirectTo string) { |
||||
_, err := actions_model.NewRunnerToken(ctx, ownerID, repoID) |
||||
if err != nil { |
||||
ctx.ServerError("ResetRunnerRegistrationToken", err) |
||||
return |
||||
} |
||||
|
||||
ctx.Flash.Success(ctx.Tr("actions.runners.reset_registration_token_success")) |
||||
ctx.Redirect(redirectTo) |
||||
} |
||||
|
||||
// RunnerDeletePost response for deleting a runner
|
||||
func RunnerDeletePost(ctx *context.Context, runnerID int64, |
||||
successRedirectTo, failedRedirectTo string, |
||||
) { |
||||
if err := actions_model.DeleteRunner(ctx, runnerID); err != nil { |
||||
log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) |
||||
ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed")) |
||||
|
||||
ctx.JSON(http.StatusOK, map[string]interface{}{ |
||||
"redirect": failedRedirectTo, |
||||
}) |
||||
return |
||||
} |
||||
|
||||
log.Info("DeleteRunnerPost success: %s", ctx.Req.URL) |
||||
|
||||
ctx.Flash.Success(ctx.Tr("actions.runners.delete_runner_success")) |
||||
|
||||
ctx.JSON(http.StatusOK, map[string]interface{}{ |
||||
"redirect": successRedirectTo, |
||||
}) |
||||
} |
||||
|
||||
func splitLabels(s string) []string { |
||||
labels := strings.Split(s, ",") |
||||
for i, v := range labels { |
||||
labels[i] = strings.TrimSpace(v) |
||||
} |
||||
return labels |
||||
} |
@ -0,0 +1,94 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/actions" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
) |
||||
|
||||
const ( |
||||
zombieTaskTimeout = 10 * time.Minute |
||||
endlessTaskTimeout = 3 * time.Hour |
||||
abandonedJobTimeout = 24 * time.Hour |
||||
) |
||||
|
||||
// StopZombieTasks stops the task which have running status, but haven't been updated for a long time
|
||||
func StopZombieTasks(ctx context.Context) error { |
||||
return stopTasks(ctx, actions_model.FindTaskOptions{ |
||||
Status: actions_model.StatusRunning, |
||||
UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-zombieTaskTimeout).Unix()), |
||||
}) |
||||
} |
||||
|
||||
// StopEndlessTasks stops the tasks which have running status and continuous updates, but don't end for a long time
|
||||
func StopEndlessTasks(ctx context.Context) error { |
||||
return stopTasks(ctx, actions_model.FindTaskOptions{ |
||||
Status: actions_model.StatusRunning, |
||||
StartedBefore: timeutil.TimeStamp(time.Now().Add(-endlessTaskTimeout).Unix()), |
||||
}) |
||||
} |
||||
|
||||
func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { |
||||
tasks, err := actions_model.FindTasks(ctx, opts) |
||||
if err != nil { |
||||
return fmt.Errorf("find tasks: %w", err) |
||||
} |
||||
|
||||
for _, task := range tasks { |
||||
if err := db.WithTx(ctx, func(ctx context.Context) error { |
||||
if err := actions_model.StopTask(ctx, task.ID, actions_model.StatusFailure); err != nil { |
||||
return err |
||||
} |
||||
if err := task.LoadJob(ctx); err != nil { |
||||
return err |
||||
} |
||||
return CreateCommitStatus(ctx, task.Job) |
||||
}); err != nil { |
||||
log.Warn("Cannot stop task %v: %v", task.ID, err) |
||||
// go on
|
||||
} else if remove, err := actions.TransferLogs(ctx, task.LogFilename); err != nil { |
||||
log.Warn("Cannot transfer logs of task %v: %v", task.ID, err) |
||||
} else { |
||||
remove() |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// CancelAbandonedJobs cancels the jobs which have waiting status, but haven't been picked by a runner for a long time
|
||||
func CancelAbandonedJobs(ctx context.Context) error { |
||||
jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{ |
||||
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked}, |
||||
UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-abandonedJobTimeout).Unix()), |
||||
}) |
||||
if err != nil { |
||||
log.Warn("find abandoned tasks: %v", err) |
||||
return err |
||||
} |
||||
|
||||
now := timeutil.TimeStampNow() |
||||
for _, job := range jobs { |
||||
job.Status = actions_model.StatusCancelled |
||||
job.Stopped = now |
||||
if err := db.WithTx(ctx, func(ctx context.Context) error { |
||||
if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped"); err != nil { |
||||
return err |
||||
} |
||||
return CreateCommitStatus(ctx, job) |
||||
}); err != nil { |
||||
log.Warn("cancel abandoned job %v: %v", job.ID, err) |
||||
// go on
|
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,88 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
git_model "code.gitea.io/gitea/models/git" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
api "code.gitea.io/gitea/modules/structs" |
||||
webhook_module "code.gitea.io/gitea/modules/webhook" |
||||
) |
||||
|
||||
func CreateCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) error { |
||||
if err := job.LoadAttributes(ctx); err != nil { |
||||
return fmt.Errorf("load run: %w", err) |
||||
} |
||||
|
||||
run := job.Run |
||||
if run.Event != webhook_module.HookEventPush { |
||||
return nil |
||||
} |
||||
|
||||
payload, err := run.GetPushEventPayload() |
||||
if err != nil { |
||||
return fmt.Errorf("GetPushEventPayload: %w", err) |
||||
} |
||||
|
||||
creator, err := user_model.GetUserByID(ctx, payload.Pusher.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("GetUserByID: %w", err) |
||||
} |
||||
|
||||
repo := run.Repo |
||||
sha := payload.HeadCommit.ID |
||||
ctxname := job.Name |
||||
state := toCommitStatus(job.Status) |
||||
|
||||
if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{}); err == nil { |
||||
for _, v := range statuses { |
||||
if v.Context == ctxname { |
||||
if v.State == state { |
||||
return nil |
||||
} |
||||
break |
||||
} |
||||
} |
||||
} else { |
||||
return fmt.Errorf("GetLatestCommitStatus: %w", err) |
||||
} |
||||
|
||||
if err := git_model.NewCommitStatus(ctx, git_model.NewCommitStatusOptions{ |
||||
Repo: repo, |
||||
SHA: payload.HeadCommit.ID, |
||||
Creator: creator, |
||||
CommitStatus: &git_model.CommitStatus{ |
||||
SHA: sha, |
||||
TargetURL: run.HTMLURL(), |
||||
Description: "", |
||||
Context: ctxname, |
||||
CreatorID: payload.Pusher.ID, |
||||
State: state, |
||||
}, |
||||
}); err != nil { |
||||
return fmt.Errorf("NewCommitStatus: %w", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func toCommitStatus(status actions_model.Status) api.CommitStatusState { |
||||
switch status { |
||||
case actions_model.StatusSuccess: |
||||
return api.CommitStatusSuccess |
||||
case actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped: |
||||
return api.CommitStatusFailure |
||||
case actions_model.StatusWaiting, actions_model.StatusBlocked: |
||||
return api.CommitStatusPending |
||||
case actions_model.StatusRunning: |
||||
return api.CommitStatusRunning |
||||
default: |
||||
return api.CommitStatusError |
||||
} |
||||
} |
@ -0,0 +1,22 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/modules/graceful" |
||||
"code.gitea.io/gitea/modules/notification" |
||||
"code.gitea.io/gitea/modules/queue" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
) |
||||
|
||||
func Init() { |
||||
if !setting.Actions.Enabled { |
||||
return |
||||
} |
||||
|
||||
jobEmitterQueue = queue.CreateUniqueQueue("actions_ready_job", jobEmitterQueueHandle, new(jobUpdate)) |
||||
go graceful.GetManager().RunWithShutdownFns(jobEmitterQueue.Run) |
||||
|
||||
notification.RegisterNotifier(NewNotifier()) |
||||
} |
@ -0,0 +1,140 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/graceful" |
||||
"code.gitea.io/gitea/modules/queue" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
var jobEmitterQueue queue.UniqueQueue |
||||
|
||||
type jobUpdate struct { |
||||
RunID int64 |
||||
} |
||||
|
||||
func EmitJobsIfReady(runID int64) error { |
||||
err := jobEmitterQueue.Push(&jobUpdate{ |
||||
RunID: runID, |
||||
}) |
||||
if errors.Is(err, queue.ErrAlreadyInQueue) { |
||||
return nil |
||||
} |
||||
return err |
||||
} |
||||
|
||||
func jobEmitterQueueHandle(data ...queue.Data) []queue.Data { |
||||
ctx := graceful.GetManager().ShutdownContext() |
||||
var ret []queue.Data |
||||
for _, d := range data { |
||||
update := d.(*jobUpdate) |
||||
if err := checkJobsOfRun(ctx, update.RunID); err != nil { |
||||
ret = append(ret, d) |
||||
} |
||||
} |
||||
return ret |
||||
} |
||||
|
||||
func checkJobsOfRun(ctx context.Context, runID int64) error { |
||||
return db.WithTx(ctx, func(ctx context.Context) error { |
||||
jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: runID}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) |
||||
for _, job := range jobs { |
||||
idToJobs[job.JobID] = append(idToJobs[job.JobID], job) |
||||
} |
||||
|
||||
updates := newJobStatusResolver(jobs).Resolve() |
||||
for _, job := range jobs { |
||||
if status, ok := updates[job.ID]; ok { |
||||
job.Status = status |
||||
if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil { |
||||
return err |
||||
} else if n != 1 { |
||||
return fmt.Errorf("no affected for updating blocked job %v", job.ID) |
||||
} |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
type jobStatusResolver struct { |
||||
statuses map[int64]actions_model.Status |
||||
needs map[int64][]int64 |
||||
} |
||||
|
||||
func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver { |
||||
idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) |
||||
for _, job := range jobs { |
||||
idToJobs[job.JobID] = append(idToJobs[job.JobID], job) |
||||
} |
||||
|
||||
statuses := make(map[int64]actions_model.Status, len(jobs)) |
||||
needs := make(map[int64][]int64, len(jobs)) |
||||
for _, job := range jobs { |
||||
statuses[job.ID] = job.Status |
||||
for _, need := range job.Needs { |
||||
for _, v := range idToJobs[need] { |
||||
needs[job.ID] = append(needs[job.ID], v.ID) |
||||
} |
||||
} |
||||
} |
||||
return &jobStatusResolver{ |
||||
statuses: statuses, |
||||
needs: needs, |
||||
} |
||||
} |
||||
|
||||
func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status { |
||||
ret := map[int64]actions_model.Status{} |
||||
for i := 0; i < len(r.statuses); i++ { |
||||
updated := r.resolve() |
||||
if len(updated) == 0 { |
||||
return ret |
||||
} |
||||
for k, v := range updated { |
||||
ret[k] = v |
||||
r.statuses[k] = v |
||||
} |
||||
} |
||||
return ret |
||||
} |
||||
|
||||
func (r *jobStatusResolver) resolve() map[int64]actions_model.Status { |
||||
ret := map[int64]actions_model.Status{} |
||||
for id, status := range r.statuses { |
||||
if status != actions_model.StatusBlocked { |
||||
continue |
||||
} |
||||
allDone, allSucceed := true, true |
||||
for _, need := range r.needs[id] { |
||||
needStatus := r.statuses[need] |
||||
if !needStatus.IsDone() { |
||||
allDone = false |
||||
} |
||||
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) { |
||||
allSucceed = false |
||||
} |
||||
} |
||||
if allDone { |
||||
if allSucceed { |
||||
ret[id] = actions_model.StatusWaiting |
||||
} else { |
||||
ret[id] = actions_model.StatusSkipped |
||||
} |
||||
} |
||||
} |
||||
return ret |
||||
} |
@ -0,0 +1,80 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func Test_jobStatusResolver_Resolve(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
jobs actions_model.ActionJobList |
||||
want map[int64]actions_model.Status |
||||
}{ |
||||
{ |
||||
name: "no blocked", |
||||
jobs: actions_model.ActionJobList{ |
||||
{ID: 1, JobID: "1", Status: actions_model.StatusWaiting, Needs: []string{}}, |
||||
{ID: 2, JobID: "2", Status: actions_model.StatusWaiting, Needs: []string{}}, |
||||
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}}, |
||||
}, |
||||
want: map[int64]actions_model.Status{}, |
||||
}, |
||||
{ |
||||
name: "single blocked", |
||||
jobs: actions_model.ActionJobList{ |
||||
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}}, |
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, |
||||
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}}, |
||||
}, |
||||
want: map[int64]actions_model.Status{ |
||||
2: actions_model.StatusWaiting, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "multiple blocked", |
||||
jobs: actions_model.ActionJobList{ |
||||
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}}, |
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, |
||||
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, |
||||
}, |
||||
want: map[int64]actions_model.Status{ |
||||
2: actions_model.StatusWaiting, |
||||
3: actions_model.StatusWaiting, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "chain blocked", |
||||
jobs: actions_model.ActionJobList{ |
||||
{ID: 1, JobID: "1", Status: actions_model.StatusFailure, Needs: []string{}}, |
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, |
||||
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}}, |
||||
}, |
||||
want: map[int64]actions_model.Status{ |
||||
2: actions_model.StatusSkipped, |
||||
3: actions_model.StatusSkipped, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "loop need", |
||||
jobs: actions_model.ActionJobList{ |
||||
{ID: 1, JobID: "1", Status: actions_model.StatusBlocked, Needs: []string{"3"}}, |
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, |
||||
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}}, |
||||
}, |
||||
want: map[int64]actions_model.Status{}, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
r := newJobStatusResolver(tt.jobs) |
||||
assert.Equal(t, tt.want, r.Resolve()) |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,528 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
issues_model "code.gitea.io/gitea/models/issues" |
||||
packages_model "code.gitea.io/gitea/models/packages" |
||||
perm_model "code.gitea.io/gitea/models/perm" |
||||
access_model "code.gitea.io/gitea/models/perm/access" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/git" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/notification/base" |
||||
"code.gitea.io/gitea/modules/repository" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
api "code.gitea.io/gitea/modules/structs" |
||||
webhook_module "code.gitea.io/gitea/modules/webhook" |
||||
"code.gitea.io/gitea/services/convert" |
||||
) |
||||
|
||||
type actionsNotifier struct { |
||||
base.NullNotifier |
||||
} |
||||
|
||||
var _ base.Notifier = &actionsNotifier{} |
||||
|
||||
// NewNotifier create a new actionsNotifier notifier
|
||||
func NewNotifier() base.Notifier { |
||||
return &actionsNotifier{} |
||||
} |
||||
|
||||
// NotifyNewIssue notifies issue created event
|
||||
func (n *actionsNotifier) NotifyNewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) { |
||||
ctx = withMethod(ctx, "NotifyNewIssue") |
||||
if err := issue.LoadRepo(ctx); err != nil { |
||||
log.Error("issue.LoadRepo: %v", err) |
||||
return |
||||
} |
||||
if err := issue.LoadPoster(ctx); err != nil { |
||||
log.Error("issue.LoadPoster: %v", err) |
||||
return |
||||
} |
||||
mode, _ := access_model.AccessLevel(ctx, issue.Poster, issue.Repo) |
||||
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).WithPayload(&api.IssuePayload{ |
||||
Action: api.HookIssueOpened, |
||||
Index: issue.Index, |
||||
Issue: convert.ToAPIIssue(ctx, issue), |
||||
Repository: convert.ToRepo(ctx, issue.Repo, mode), |
||||
Sender: convert.ToUser(issue.Poster, nil), |
||||
}).Notify(withMethod(ctx, "NotifyNewIssue")) |
||||
} |
||||
|
||||
// NotifyIssueChangeStatus notifies close or reopen issue to notifiers
|
||||
func (n *actionsNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) { |
||||
ctx = withMethod(ctx, "NotifyIssueChangeStatus") |
||||
mode, _ := access_model.AccessLevel(ctx, issue.Poster, issue.Repo) |
||||
if issue.IsPull { |
||||
if err := issue.LoadPullRequest(ctx); err != nil { |
||||
log.Error("LoadPullRequest: %v", err) |
||||
return |
||||
} |
||||
// Merge pull request calls issue.changeStatus so we need to handle separately.
|
||||
apiPullRequest := &api.PullRequestPayload{ |
||||
Index: issue.Index, |
||||
PullRequest: convert.ToAPIPullRequest(db.DefaultContext, issue.PullRequest, nil), |
||||
Repository: convert.ToRepo(ctx, issue.Repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
CommitID: commitID, |
||||
} |
||||
if isClosed { |
||||
apiPullRequest.Action = api.HookIssueClosed |
||||
} else { |
||||
apiPullRequest.Action = api.HookIssueReOpened |
||||
} |
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequest). |
||||
WithDoer(doer). |
||||
WithPayload(apiPullRequest). |
||||
Notify(ctx) |
||||
return |
||||
} |
||||
apiIssue := &api.IssuePayload{ |
||||
Index: issue.Index, |
||||
Issue: convert.ToAPIIssue(ctx, issue), |
||||
Repository: convert.ToRepo(ctx, issue.Repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
} |
||||
if isClosed { |
||||
apiIssue.Action = api.HookIssueClosed |
||||
} else { |
||||
apiIssue.Action = api.HookIssueReOpened |
||||
} |
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssues). |
||||
WithDoer(doer). |
||||
WithPayload(apiIssue). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyIssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, |
||||
_, _ []*issues_model.Label, |
||||
) { |
||||
ctx = withMethod(ctx, "NotifyIssueChangeLabels") |
||||
|
||||
var err error |
||||
if err = issue.LoadRepo(ctx); err != nil { |
||||
log.Error("LoadRepo: %v", err) |
||||
return |
||||
} |
||||
|
||||
if err = issue.LoadPoster(ctx); err != nil { |
||||
log.Error("LoadPoster: %v", err) |
||||
return |
||||
} |
||||
|
||||
mode, _ := access_model.AccessLevel(ctx, issue.Poster, issue.Repo) |
||||
if issue.IsPull { |
||||
if err = issue.LoadPullRequest(ctx); err != nil { |
||||
log.Error("loadPullRequest: %v", err) |
||||
return |
||||
} |
||||
if err = issue.PullRequest.LoadIssue(ctx); err != nil { |
||||
log.Error("LoadIssue: %v", err) |
||||
return |
||||
} |
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestLabel). |
||||
WithDoer(doer). |
||||
WithPayload(&api.PullRequestPayload{ |
||||
Action: api.HookIssueLabelUpdated, |
||||
Index: issue.Index, |
||||
PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil), |
||||
Repository: convert.ToRepo(ctx, issue.Repo, perm_model.AccessModeNone), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}). |
||||
Notify(ctx) |
||||
return |
||||
} |
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssueLabel). |
||||
WithDoer(doer). |
||||
WithPayload(&api.IssuePayload{ |
||||
Action: api.HookIssueLabelUpdated, |
||||
Index: issue.Index, |
||||
Issue: convert.ToAPIIssue(ctx, issue), |
||||
Repository: convert.ToRepo(ctx, issue.Repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
// NotifyCreateIssueComment notifies comment on an issue to notifiers
|
||||
func (n *actionsNotifier) NotifyCreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, |
||||
issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User, |
||||
) { |
||||
ctx = withMethod(ctx, "NotifyCreateIssueComment") |
||||
|
||||
mode, _ := access_model.AccessLevel(ctx, doer, repo) |
||||
|
||||
if issue.IsPull { |
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestComment). |
||||
WithDoer(doer). |
||||
WithPayload(&api.IssueCommentPayload{ |
||||
Action: api.HookIssueCommentCreated, |
||||
Issue: convert.ToAPIIssue(ctx, issue), |
||||
Comment: convert.ToComment(comment), |
||||
Repository: convert.ToRepo(ctx, repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
IsPull: true, |
||||
}). |
||||
Notify(ctx) |
||||
return |
||||
} |
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssueComment). |
||||
WithDoer(doer). |
||||
WithPayload(&api.IssueCommentPayload{ |
||||
Action: api.HookIssueCommentCreated, |
||||
Issue: convert.ToAPIIssue(ctx, issue), |
||||
Comment: convert.ToComment(comment), |
||||
Repository: convert.ToRepo(ctx, repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
IsPull: false, |
||||
}). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues_model.PullRequest, _ []*user_model.User) { |
||||
ctx = withMethod(ctx, "NotifyNewPullRequest") |
||||
|
||||
if err := pull.LoadIssue(ctx); err != nil { |
||||
log.Error("pull.LoadIssue: %v", err) |
||||
return |
||||
} |
||||
if err := pull.Issue.LoadRepo(ctx); err != nil { |
||||
log.Error("pull.Issue.LoadRepo: %v", err) |
||||
return |
||||
} |
||||
if err := pull.Issue.LoadPoster(ctx); err != nil { |
||||
log.Error("pull.Issue.LoadPoster: %v", err) |
||||
return |
||||
} |
||||
|
||||
mode, _ := access_model.AccessLevel(ctx, pull.Issue.Poster, pull.Issue.Repo) |
||||
|
||||
newNotifyInputFromIssue(pull.Issue, webhook_module.HookEventPullRequest). |
||||
WithPayload(&api.PullRequestPayload{ |
||||
Action: api.HookIssueOpened, |
||||
Index: pull.Issue.Index, |
||||
PullRequest: convert.ToAPIPullRequest(ctx, pull, nil), |
||||
Repository: convert.ToRepo(ctx, pull.Issue.Repo, mode), |
||||
Sender: convert.ToUser(pull.Issue.Poster, nil), |
||||
}). |
||||
WithPullRequest(pull). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyCreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { |
||||
ctx = withMethod(ctx, "NotifyCreateRepository") |
||||
|
||||
newNotifyInput(repo, doer, webhook_module.HookEventRepository).WithPayload(&api.RepositoryPayload{ |
||||
Action: api.HookRepoCreated, |
||||
Repository: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), |
||||
Organization: convert.ToUser(u, nil), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}).Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { |
||||
ctx = withMethod(ctx, "NotifyForkRepository") |
||||
|
||||
oldMode, _ := access_model.AccessLevel(ctx, doer, oldRepo) |
||||
mode, _ := access_model.AccessLevel(ctx, doer, repo) |
||||
|
||||
// forked webhook
|
||||
newNotifyInput(oldRepo, doer, webhook_module.HookEventFork).WithPayload(&api.ForkPayload{ |
||||
Forkee: convert.ToRepo(ctx, oldRepo, oldMode), |
||||
Repo: convert.ToRepo(ctx, repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}).Notify(ctx) |
||||
|
||||
u := repo.MustOwner(ctx) |
||||
|
||||
// Add to hook queue for created repo after session commit.
|
||||
if u.IsOrganization() { |
||||
newNotifyInput(repo, doer, webhook_module.HookEventRepository). |
||||
WithRef(oldRepo.DefaultBranch). |
||||
WithPayload(&api.RepositoryPayload{ |
||||
Action: api.HookRepoCreated, |
||||
Repository: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), |
||||
Organization: convert.ToUser(u, nil), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}).Notify(ctx) |
||||
} |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyPullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, _ *issues_model.Comment, _ []*user_model.User) { |
||||
ctx = withMethod(ctx, "NotifyPullRequestReview") |
||||
|
||||
var reviewHookType webhook_module.HookEventType |
||||
|
||||
switch review.Type { |
||||
case issues_model.ReviewTypeApprove: |
||||
reviewHookType = webhook_module.HookEventPullRequestReviewApproved |
||||
case issues_model.ReviewTypeComment: |
||||
reviewHookType = webhook_module.HookEventPullRequestComment |
||||
case issues_model.ReviewTypeReject: |
||||
reviewHookType = webhook_module.HookEventPullRequestReviewRejected |
||||
default: |
||||
// unsupported review webhook type here
|
||||
log.Error("Unsupported review webhook type") |
||||
return |
||||
} |
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil { |
||||
log.Error("pr.LoadIssue: %v", err) |
||||
return |
||||
} |
||||
|
||||
mode, err := access_model.AccessLevel(ctx, review.Issue.Poster, review.Issue.Repo) |
||||
if err != nil { |
||||
log.Error("models.AccessLevel: %v", err) |
||||
return |
||||
} |
||||
|
||||
newNotifyInput(review.Issue.Repo, review.Reviewer, reviewHookType). |
||||
WithRef(review.CommitID). |
||||
WithPayload(&api.PullRequestPayload{ |
||||
Action: api.HookIssueReviewed, |
||||
Index: review.Issue.Index, |
||||
PullRequest: convert.ToAPIPullRequest(db.DefaultContext, pr, nil), |
||||
Repository: convert.ToRepo(ctx, review.Issue.Repo, mode), |
||||
Sender: convert.ToUser(review.Reviewer, nil), |
||||
Review: &api.ReviewPayload{ |
||||
Type: string(reviewHookType), |
||||
Content: review.Content, |
||||
}, |
||||
}).Notify(ctx) |
||||
} |
||||
|
||||
func (*actionsNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { |
||||
ctx = withMethod(ctx, "NotifyMergePullRequest") |
||||
|
||||
// Reload pull request information.
|
||||
if err := pr.LoadAttributes(ctx); err != nil { |
||||
log.Error("LoadAttributes: %v", err) |
||||
return |
||||
} |
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil { |
||||
log.Error("LoadAttributes: %v", err) |
||||
return |
||||
} |
||||
|
||||
if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { |
||||
log.Error("pr.Issue.LoadRepo: %v", err) |
||||
return |
||||
} |
||||
|
||||
mode, err := access_model.AccessLevel(ctx, doer, pr.Issue.Repo) |
||||
if err != nil { |
||||
log.Error("models.AccessLevel: %v", err) |
||||
return |
||||
} |
||||
|
||||
// Merge pull request calls issue.changeStatus so we need to handle separately.
|
||||
apiPullRequest := &api.PullRequestPayload{ |
||||
Index: pr.Issue.Index, |
||||
PullRequest: convert.ToAPIPullRequest(db.DefaultContext, pr, nil), |
||||
Repository: convert.ToRepo(ctx, pr.Issue.Repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
Action: api.HookIssueClosed, |
||||
} |
||||
|
||||
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequest). |
||||
WithRef(pr.MergedCommitID). |
||||
WithPayload(apiPullRequest). |
||||
WithPullRequest(pr). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { |
||||
ctx = withMethod(ctx, "NotifyPushCommits") |
||||
|
||||
apiPusher := convert.ToUser(pusher, nil) |
||||
apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL()) |
||||
if err != nil { |
||||
log.Error("commits.ToAPIPayloadCommits failed: %v", err) |
||||
return |
||||
} |
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventPush). |
||||
WithRef(opts.RefFullName). |
||||
WithPayload(&api.PushPayload{ |
||||
Ref: opts.RefFullName, |
||||
Before: opts.OldCommitID, |
||||
After: opts.NewCommitID, |
||||
CompareURL: setting.AppURL + commits.CompareURL, |
||||
Commits: apiCommits, |
||||
HeadCommit: apiHeadCommit, |
||||
Repo: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), |
||||
Pusher: apiPusher, |
||||
Sender: apiPusher, |
||||
}). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName, refID string) { |
||||
ctx = withMethod(ctx, "NotifyCreateRef") |
||||
|
||||
apiPusher := convert.ToUser(pusher, nil) |
||||
apiRepo := convert.ToRepo(ctx, repo, perm_model.AccessModeNone) |
||||
refName := git.RefEndName(refFullName) |
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventCreate). |
||||
WithRef(refName). |
||||
WithPayload(&api.CreatePayload{ |
||||
Ref: refName, |
||||
Sha: refID, |
||||
RefType: refType, |
||||
Repo: apiRepo, |
||||
Sender: apiPusher, |
||||
}). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName string) { |
||||
ctx = withMethod(ctx, "NotifyDeleteRef") |
||||
|
||||
apiPusher := convert.ToUser(pusher, nil) |
||||
apiRepo := convert.ToRepo(ctx, repo, perm_model.AccessModeNone) |
||||
refName := git.RefEndName(refFullName) |
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventDelete). |
||||
WithRef(refName). |
||||
WithPayload(&api.DeletePayload{ |
||||
Ref: refName, |
||||
RefType: refType, |
||||
PusherType: api.PusherTypeUser, |
||||
Repo: apiRepo, |
||||
Sender: apiPusher, |
||||
}). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifySyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { |
||||
ctx = withMethod(ctx, "NotifySyncPushCommits") |
||||
|
||||
apiPusher := convert.ToUser(pusher, nil) |
||||
apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(db.DefaultContext, repo.RepoPath(), repo.HTMLURL()) |
||||
if err != nil { |
||||
log.Error("commits.ToAPIPayloadCommits failed: %v", err) |
||||
return |
||||
} |
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventPush). |
||||
WithRef(opts.RefFullName). |
||||
WithPayload(&api.PushPayload{ |
||||
Ref: opts.RefFullName, |
||||
Before: opts.OldCommitID, |
||||
After: opts.NewCommitID, |
||||
CompareURL: setting.AppURL + commits.CompareURL, |
||||
Commits: apiCommits, |
||||
TotalCommits: commits.Len, |
||||
HeadCommit: apiHeadCommit, |
||||
Repo: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), |
||||
Pusher: apiPusher, |
||||
Sender: apiPusher, |
||||
}). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifySyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName, refID string) { |
||||
ctx = withMethod(ctx, "NotifySyncCreateRef") |
||||
n.NotifyCreateRef(ctx, pusher, repo, refType, refFullName, refID) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifySyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName string) { |
||||
ctx = withMethod(ctx, "NotifySyncDeleteRef") |
||||
n.NotifyDeleteRef(ctx, pusher, repo, refType, refFullName) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyNewRelease(ctx context.Context, rel *repo_model.Release) { |
||||
ctx = withMethod(ctx, "NotifyNewRelease") |
||||
notifyRelease(ctx, rel.Publisher, rel, rel.Sha1, api.HookReleasePublished) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyUpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { |
||||
ctx = withMethod(ctx, "NotifyUpdateRelease") |
||||
notifyRelease(ctx, doer, rel, rel.Sha1, api.HookReleaseUpdated) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyDeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { |
||||
ctx = withMethod(ctx, "NotifyDeleteRelease") |
||||
notifyRelease(ctx, doer, rel, rel.Sha1, api.HookReleaseDeleted) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyPackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { |
||||
ctx = withMethod(ctx, "NotifyPackageCreate") |
||||
notifyPackage(ctx, doer, pd, api.HookPackageCreated) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyPackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { |
||||
ctx = withMethod(ctx, "NotifyPackageDelete") |
||||
notifyPackage(ctx, doer, pd, api.HookPackageDeleted) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyAutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { |
||||
ctx = withMethod(ctx, "NotifyAutoMergePullRequest") |
||||
n.NotifyMergePullRequest(ctx, doer, pr) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyPullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { |
||||
ctx = withMethod(ctx, "NotifyPullRequestSynchronized") |
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil { |
||||
log.Error("LoadAttributes: %v", err) |
||||
return |
||||
} |
||||
|
||||
if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { |
||||
log.Error("pr.Issue.LoadRepo: %v", err) |
||||
return |
||||
} |
||||
|
||||
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequestSync). |
||||
WithPayload(&api.PullRequestPayload{ |
||||
Action: api.HookIssueSynchronized, |
||||
Index: pr.Issue.Index, |
||||
PullRequest: convert.ToAPIPullRequest(ctx, pr, nil), |
||||
Repository: convert.ToRepo(ctx, pr.Issue.Repo, perm_model.AccessModeNone), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}). |
||||
WithPullRequest(pr). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func (n *actionsNotifier) NotifyPullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { |
||||
ctx = withMethod(ctx, "NotifyPullRequestChangeTargetBranch") |
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil { |
||||
log.Error("LoadAttributes: %v", err) |
||||
return |
||||
} |
||||
|
||||
if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { |
||||
log.Error("pr.Issue.LoadRepo: %v", err) |
||||
return |
||||
} |
||||
|
||||
mode, _ := access_model.AccessLevel(ctx, pr.Issue.Poster, pr.Issue.Repo) |
||||
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequest). |
||||
WithPayload(&api.PullRequestPayload{ |
||||
Action: api.HookIssueEdited, |
||||
Index: pr.Issue.Index, |
||||
Changes: &api.ChangesPayload{ |
||||
Ref: &api.ChangesFromPayload{ |
||||
From: oldBranch, |
||||
}, |
||||
}, |
||||
PullRequest: convert.ToAPIPullRequest(ctx, pr, nil), |
||||
Repository: convert.ToRepo(ctx, pr.Issue.Repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}). |
||||
WithPullRequest(pr). |
||||
Notify(ctx) |
||||
} |
@ -0,0 +1,229 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
issues_model "code.gitea.io/gitea/models/issues" |
||||
packages_model "code.gitea.io/gitea/models/packages" |
||||
access_model "code.gitea.io/gitea/models/perm/access" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
unit_model "code.gitea.io/gitea/models/unit" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
actions_module "code.gitea.io/gitea/modules/actions" |
||||
"code.gitea.io/gitea/modules/git" |
||||
"code.gitea.io/gitea/modules/json" |
||||
"code.gitea.io/gitea/modules/log" |
||||
api "code.gitea.io/gitea/modules/structs" |
||||
webhook_module "code.gitea.io/gitea/modules/webhook" |
||||
"code.gitea.io/gitea/services/convert" |
||||
|
||||
"github.com/nektos/act/pkg/jobparser" |
||||
) |
||||
|
||||
var methodCtxKey struct{} |
||||
|
||||
// withMethod sets the notification method that this context currently executes.
|
||||
// Used for debugging/ troubleshooting purposes.
|
||||
func withMethod(ctx context.Context, method string) context.Context { |
||||
// don't overwrite
|
||||
if v := ctx.Value(methodCtxKey); v != nil { |
||||
if _, ok := v.(string); ok { |
||||
return ctx |
||||
} |
||||
} |
||||
return context.WithValue(ctx, methodCtxKey, method) |
||||
} |
||||
|
||||
// getMethod gets the notification method that this context currently executes.
|
||||
// Default: "notify"
|
||||
// Used for debugging/ troubleshooting purposes.
|
||||
func getMethod(ctx context.Context) string { |
||||
if v := ctx.Value(methodCtxKey); v != nil { |
||||
if s, ok := v.(string); ok { |
||||
return s |
||||
} |
||||
} |
||||
return "notify" |
||||
} |
||||
|
||||
type notifyInput struct { |
||||
// required
|
||||
Repo *repo_model.Repository |
||||
Doer *user_model.User |
||||
Event webhook_module.HookEventType |
||||
|
||||
// optional
|
||||
Ref string |
||||
Payload api.Payloader |
||||
PullRequest *issues_model.PullRequest |
||||
} |
||||
|
||||
func newNotifyInput(repo *repo_model.Repository, doer *user_model.User, event webhook_module.HookEventType) *notifyInput { |
||||
return ¬ifyInput{ |
||||
Repo: repo, |
||||
Ref: repo.DefaultBranch, |
||||
Doer: doer, |
||||
Event: event, |
||||
} |
||||
} |
||||
|
||||
func (input *notifyInput) WithDoer(doer *user_model.User) *notifyInput { |
||||
input.Doer = doer |
||||
return input |
||||
} |
||||
|
||||
func (input *notifyInput) WithRef(ref string) *notifyInput { |
||||
input.Ref = ref |
||||
return input |
||||
} |
||||
|
||||
func (input *notifyInput) WithPayload(payload api.Payloader) *notifyInput { |
||||
input.Payload = payload |
||||
return input |
||||
} |
||||
|
||||
func (input *notifyInput) WithPullRequest(pr *issues_model.PullRequest) *notifyInput { |
||||
input.PullRequest = pr |
||||
return input |
||||
} |
||||
|
||||
func (input *notifyInput) Notify(ctx context.Context) { |
||||
log.Trace("execute %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name) |
||||
|
||||
if err := notify(ctx, input); err != nil { |
||||
log.Error("an error occurred while executing the %s actions method: %v", getMethod(ctx), err) |
||||
} |
||||
} |
||||
|
||||
func notify(ctx context.Context, input *notifyInput) error { |
||||
if input.Doer.IsActions() { |
||||
// avoiding triggering cyclically, for example:
|
||||
// a comment of an issue will trigger the runner to add a new comment as reply,
|
||||
// and the new comment will trigger the runner again.
|
||||
log.Debug("ignore executing %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name) |
||||
return nil |
||||
} |
||||
if unit_model.TypeActions.UnitGlobalDisabled() { |
||||
return nil |
||||
} |
||||
if err := input.Repo.LoadUnits(ctx); err != nil { |
||||
return fmt.Errorf("repo.LoadUnits: %w", err) |
||||
} else if !input.Repo.UnitEnabled(ctx, unit_model.TypeActions) { |
||||
return nil |
||||
} |
||||
|
||||
gitRepo, err := git.OpenRepository(context.Background(), input.Repo.RepoPath()) |
||||
if err != nil { |
||||
return fmt.Errorf("git.OpenRepository: %w", err) |
||||
} |
||||
defer gitRepo.Close() |
||||
|
||||
// Get the commit object for the ref
|
||||
commit, err := gitRepo.GetCommit(input.Ref) |
||||
if err != nil { |
||||
return fmt.Errorf("gitRepo.GetCommit: %w", err) |
||||
} |
||||
|
||||
workflows, err := actions_module.DetectWorkflows(commit, input.Event) |
||||
if err != nil { |
||||
return fmt.Errorf("DetectWorkflows: %w", err) |
||||
} |
||||
|
||||
if len(workflows) == 0 { |
||||
log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RepoPath(), commit.ID) |
||||
return nil |
||||
} |
||||
|
||||
p, err := json.Marshal(input.Payload) |
||||
if err != nil { |
||||
return fmt.Errorf("json.Marshal: %w", err) |
||||
} |
||||
|
||||
for id, content := range workflows { |
||||
run := actions_model.ActionRun{ |
||||
Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0], |
||||
RepoID: input.Repo.ID, |
||||
OwnerID: input.Repo.OwnerID, |
||||
WorkflowID: id, |
||||
TriggerUserID: input.Doer.ID, |
||||
Ref: input.Ref, |
||||
CommitSHA: commit.ID.String(), |
||||
IsForkPullRequest: input.PullRequest != nil && input.PullRequest.IsFromFork(), |
||||
Event: input.Event, |
||||
EventPayload: string(p), |
||||
Status: actions_model.StatusWaiting, |
||||
} |
||||
jobs, err := jobparser.Parse(content) |
||||
if err != nil { |
||||
log.Error("jobparser.Parse: %v", err) |
||||
continue |
||||
} |
||||
if err := actions_model.InsertRun(ctx, &run, jobs); err != nil { |
||||
log.Error("InsertRun: %v", err) |
||||
continue |
||||
} |
||||
if jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: run.ID}); err != nil { |
||||
log.Error("FindRunJobs: %v", err) |
||||
} else { |
||||
for _, job := range jobs { |
||||
if err := CreateCommitStatus(ctx, job); err != nil { |
||||
log.Error("CreateCommitStatus: %v", err) |
||||
} |
||||
} |
||||
} |
||||
|
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput { |
||||
return newNotifyInput(issue.Repo, issue.Poster, event) |
||||
} |
||||
|
||||
func notifyRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release, ref string, action api.HookReleaseAction) { |
||||
if err := rel.LoadAttributes(ctx); err != nil { |
||||
log.Error("LoadAttributes: %v", err) |
||||
return |
||||
} |
||||
|
||||
mode, _ := access_model.AccessLevel(ctx, doer, rel.Repo) |
||||
|
||||
newNotifyInput(rel.Repo, doer, webhook_module.HookEventRelease). |
||||
WithRef(ref). |
||||
WithPayload(&api.ReleasePayload{ |
||||
Action: action, |
||||
Release: convert.ToRelease(rel), |
||||
Repository: convert.ToRepo(ctx, rel.Repo, mode), |
||||
Sender: convert.ToUser(doer, nil), |
||||
}). |
||||
Notify(ctx) |
||||
} |
||||
|
||||
func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_model.PackageDescriptor, action api.HookPackageAction) { |
||||
if pd.Repository == nil { |
||||
// When a package is uploaded to an organization, it could trigger an event to notify.
|
||||
// So the repository could be nil, however, actions can't support that yet.
|
||||
// See https://github.com/go-gitea/gitea/pull/17940
|
||||
return |
||||
} |
||||
|
||||
apiPackage, err := convert.ToPackage(ctx, pd, sender) |
||||
if err != nil { |
||||
log.Error("Error converting package: %v", err) |
||||
return |
||||
} |
||||
|
||||
newNotifyInput(pd.Repository, sender, webhook_module.HookEventPackage). |
||||
WithPayload(&api.PackagePayload{ |
||||
Action: action, |
||||
Package: apiPackage, |
||||
Sender: convert.ToUser(sender, nil), |
||||
}). |
||||
Notify(ctx) |
||||
} |
@ -0,0 +1,51 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cron |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
actions_service "code.gitea.io/gitea/services/actions" |
||||
) |
||||
|
||||
func initActionsTasks() { |
||||
if !setting.Actions.Enabled { |
||||
return |
||||
} |
||||
registerStopZombieTasks() |
||||
registerStopEndlessTasks() |
||||
registerCancelAbandonedJobs() |
||||
} |
||||
|
||||
func registerStopZombieTasks() { |
||||
RegisterTaskFatal("stop_zombie_tasks", &BaseConfig{ |
||||
Enabled: true, |
||||
RunAtStart: true, |
||||
Schedule: "@every 5m", |
||||
}, func(ctx context.Context, _ *user_model.User, cfg Config) error { |
||||
return actions_service.StopZombieTasks(ctx) |
||||
}) |
||||
} |
||||
|
||||
func registerStopEndlessTasks() { |
||||
RegisterTaskFatal("stop_endless_tasks", &BaseConfig{ |
||||
Enabled: true, |
||||
RunAtStart: true, |
||||
Schedule: "@every 30m", |
||||
}, func(ctx context.Context, _ *user_model.User, cfg Config) error { |
||||
return actions_service.StopEndlessTasks(ctx) |
||||
}) |
||||
} |
||||
|
||||
func registerCancelAbandonedJobs() { |
||||
RegisterTaskFatal("cancel_abandoned_jobs", &BaseConfig{ |
||||
Enabled: true, |
||||
RunAtStart: true, |
||||
Schedule: "@every 6h", |
||||
}, func(ctx context.Context, _ *user_model.User, cfg Config) error { |
||||
return actions_service.CancelAbandonedJobs(ctx) |
||||
}) |
||||
} |
@ -0,0 +1,25 @@ |
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forms |
||||
|
||||
import ( |
||||
"net/http" |
||||
|
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/web/middleware" |
||||
|
||||
"gitea.com/go-chi/binding" |
||||
) |
||||
|
||||
// EditRunnerForm form for admin to create runner
|
||||
type EditRunnerForm struct { |
||||
Description string |
||||
CustomLabels string // comma-separated
|
||||
} |
||||
|
||||
// Validate validates form fields
|
||||
func (f *EditRunnerForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { |
||||
ctx := context.GetContext(req) |
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale) |
||||
} |
@ -0,0 +1,8 @@ |
||||
{{template "base/head" .}} |
||||
<div class="page-content admin runners"> |
||||
{{template "admin/navbar" .}} |
||||
<div class="ui container"> |
||||
{{template "shared/actions/runner_list" .}} |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,8 @@ |
||||
{{template "base/head" .}} |
||||
<div class="page-content admin runners"> |
||||
{{template "admin/navbar" .}} |
||||
<div class="ui container"> |
||||
{{template "shared/actions/runner_edit" .}} |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,13 @@ |
||||
{{template "base/head" .}} |
||||
<div class="page-content organization settings runners"> |
||||
{{template "org/header" .}} |
||||
<div class="ui container"> |
||||
<div class="ui grid"> |
||||
{{template "org/settings/navbar" .}} |
||||
<div class="twelve wide column content"> |
||||
{{template "shared/actions/runner_list" .}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,13 @@ |
||||
{{template "base/head" .}} |
||||
<div class="page-content organization settings runners"> |
||||
{{template "org/header" .}} |
||||
<div class="ui container"> |
||||
<div class="ui grid"> |
||||
{{template "org/settings/navbar" .}} |
||||
<div class="twelve wide column content"> |
||||
{{template "shared/actions/runner_edit" .}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,27 @@ |
||||
{{template "base/head" .}} |
||||
<div class="page-content repository"> |
||||
{{template "repo/header" .}} |
||||
|
||||
<div class="ui container"> |
||||
<div class="ui grid"> |
||||
<div class="four wide column"> |
||||
<div class="ui fluid vertical menu"> |
||||
<a class="item{{if not $.CurWorkflow}} active{{end}}" href="{{$.Link}}">{{.locale.Tr "actions.runs.all_workflows"}}</a> |
||||
<div class="divider"></div> |
||||
{{range .workflows}} |
||||
<a class="item{{if eq .Name $.CurWorkflow}} active{{end}}" href="{{$.Link}}?workflow={{.Name}}">{{.Name}}</a> |
||||
{{end}} |
||||
</div> |
||||
</div> |
||||
<div class="twelve wide column content"> |
||||
<div id="issue-filters" class="ui stackable grid"> |
||||
<div class="six wide column"> |
||||
{{template "repo/actions/openclose" .}} |
||||
</div> |
||||
</div> |
||||
{{template "repo/actions/runs_list" .}} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{template "base/footer" .}} |
@ -0,0 +1,10 @@ |
||||
<div class="ui compact tiny menu"> |
||||
<a class="{{if not .IsShowClosed}}active {{end}}item" href="{{$.Link}}?workflow={{.CurWorkflow}}&state=open"> |
||||
{{svg "octicon-issue-opened" 16 "mr-3"}} |
||||
{{.locale.Tr "actions.runs.open_tab" $.NumOpenActionRuns}} |
||||
</a> |
||||
<a class="{{if .IsShowClosed}}active {{end}}item" href="{{$.Link}}?workflow={{.CurWorkflow}}&state=closed"> |
||||
{{svg "octicon-issue-closed" 16 "mr-3"}} |
||||
{{.locale.Tr "actions.runs.closed_tab" $.NumClosedActionRuns}} |
||||
</a> |
||||
</div> |
@ -0,0 +1,25 @@ |
||||
<div class="issue list"> |
||||
{{range .Runs}} |
||||
<li class="item df py-3"> |
||||
<div class="issue-item-left df"> |
||||
{{template "repo/actions/status" .Status}} |
||||
</div> |
||||
<div class="issue-item-main f1 fc df"> |
||||
<div class="issue-item-top-row"> |
||||
<a class="index ml-0 mr-2" href="{{if .HTMLURL}}{{.HTMLURL}}{{else}}{{$.Link}}/{{.Index}}{{end}}"> |
||||
{{.Title}} <span class="ui label">{{RefShortName .Ref}}</span> |
||||
</a> |
||||
</div> |
||||
<div class="desc issue-item-bottom-row df ac fw my-1"> |
||||
<b>{{if not $.CurWorkflow}}{{.WorkflowID}} {{end}}#{{.Index}}</b>: {{$.locale.Tr "actions.runs.commit"}} |
||||
<a href="{{$.RepoLink}}/commit/{{.CommitSHA}}">{{ShortSha .CommitSHA}}</a> {{$.locale.Tr "actions.runs.pushed_by"}} {{.TriggerUser.GetDisplayName | Escape}} |
||||
</div> |
||||
</div> |
||||
<div class="issue-item-right"> |
||||
<div>{{TimeSinceUnix .Updated $.locale}}</div> |
||||
<div>{{.Duration}}</div> |
||||
</div> |
||||
</li> |
||||
{{end}} |
||||
</div> |
||||
{{template "base/paginate" .}} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue