code.gitea.io/gitea@v1.22.3/models/actions/run.go (about)

     1  // Copyright 2022 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package actions
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"slices"
    10  	"strings"
    11  	"time"
    12  
    13  	"code.gitea.io/gitea/models/db"
    14  	repo_model "code.gitea.io/gitea/models/repo"
    15  	user_model "code.gitea.io/gitea/models/user"
    16  	"code.gitea.io/gitea/modules/git"
    17  	"code.gitea.io/gitea/modules/json"
    18  	api "code.gitea.io/gitea/modules/structs"
    19  	"code.gitea.io/gitea/modules/timeutil"
    20  	"code.gitea.io/gitea/modules/util"
    21  	webhook_module "code.gitea.io/gitea/modules/webhook"
    22  
    23  	"github.com/nektos/act/pkg/jobparser"
    24  	"xorm.io/builder"
    25  )
    26  
    27  // ActionRun represents a run of a workflow file
    28  type ActionRun struct {
    29  	ID                int64
    30  	Title             string
    31  	RepoID            int64                  `xorm:"index unique(repo_index)"`
    32  	Repo              *repo_model.Repository `xorm:"-"`
    33  	OwnerID           int64                  `xorm:"index"`
    34  	WorkflowID        string                 `xorm:"index"`                    // the name of workflow file
    35  	Index             int64                  `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
    36  	TriggerUserID     int64                  `xorm:"index"`
    37  	TriggerUser       *user_model.User       `xorm:"-"`
    38  	ScheduleID        int64
    39  	Ref               string `xorm:"index"` // the commit/tag/… that caused the run
    40  	CommitSHA         string
    41  	IsForkPullRequest bool                         // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow.
    42  	NeedApproval      bool                         // may need approval if it's a fork pull request
    43  	ApprovedBy        int64                        `xorm:"index"` // who approved
    44  	Event             webhook_module.HookEventType // the webhook event that causes the workflow to run
    45  	EventPayload      string                       `xorm:"LONGTEXT"`
    46  	TriggerEvent      string                       // the trigger event defined in the `on` configuration of the triggered workflow
    47  	Status            Status                       `xorm:"index"`
    48  	Version           int                          `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
    49  	// Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0
    50  	Started timeutil.TimeStamp
    51  	Stopped timeutil.TimeStamp
    52  	// PreviousDuration is used for recording previous duration
    53  	PreviousDuration time.Duration
    54  	Created          timeutil.TimeStamp `xorm:"created"`
    55  	Updated          timeutil.TimeStamp `xorm:"updated"`
    56  }
    57  
    58  func init() {
    59  	db.RegisterModel(new(ActionRun))
    60  	db.RegisterModel(new(ActionRunIndex))
    61  }
    62  
    63  func (run *ActionRun) HTMLURL() string {
    64  	if run.Repo == nil {
    65  		return ""
    66  	}
    67  	return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(), run.Index)
    68  }
    69  
    70  func (run *ActionRun) Link() string {
    71  	if run.Repo == nil {
    72  		return ""
    73  	}
    74  	return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.Index)
    75  }
    76  
    77  func (run *ActionRun) WorkflowLink() string {
    78  	if run.Repo == nil {
    79  		return ""
    80  	}
    81  	return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), run.WorkflowID)
    82  }
    83  
    84  // RefLink return the url of run's ref
    85  func (run *ActionRun) RefLink() string {
    86  	refName := git.RefName(run.Ref)
    87  	if refName.IsPull() {
    88  		return run.Repo.Link() + "/pulls/" + refName.ShortName()
    89  	}
    90  	return git.RefURL(run.Repo.Link(), run.Ref)
    91  }
    92  
    93  // PrettyRef return #id for pull ref or ShortName for others
    94  func (run *ActionRun) PrettyRef() string {
    95  	refName := git.RefName(run.Ref)
    96  	if refName.IsPull() {
    97  		return "#" + strings.TrimSuffix(strings.TrimPrefix(run.Ref, git.PullPrefix), "/head")
    98  	}
    99  	return refName.ShortName()
   100  }
   101  
   102  // LoadAttributes load Repo TriggerUser if not loaded
   103  func (run *ActionRun) LoadAttributes(ctx context.Context) error {
   104  	if run == nil {
   105  		return nil
   106  	}
   107  
   108  	if err := run.LoadRepo(ctx); err != nil {
   109  		return err
   110  	}
   111  
   112  	if err := run.Repo.LoadAttributes(ctx); err != nil {
   113  		return err
   114  	}
   115  
   116  	if run.TriggerUser == nil {
   117  		u, err := user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
   118  		if err != nil {
   119  			return err
   120  		}
   121  		run.TriggerUser = u
   122  	}
   123  
   124  	return nil
   125  }
   126  
   127  func (run *ActionRun) LoadRepo(ctx context.Context) error {
   128  	if run == nil || run.Repo != nil {
   129  		return nil
   130  	}
   131  
   132  	repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
   133  	if err != nil {
   134  		return err
   135  	}
   136  	run.Repo = repo
   137  	return nil
   138  }
   139  
   140  func (run *ActionRun) Duration() time.Duration {
   141  	return calculateDuration(run.Started, run.Stopped, run.Status) + run.PreviousDuration
   142  }
   143  
   144  func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) {
   145  	if run.Event == webhook_module.HookEventPush {
   146  		var payload api.PushPayload
   147  		if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
   148  			return nil, err
   149  		}
   150  		return &payload, nil
   151  	}
   152  	return nil, fmt.Errorf("event %s is not a push event", run.Event)
   153  }
   154  
   155  func (run *ActionRun) GetPullRequestEventPayload() (*api.PullRequestPayload, error) {
   156  	if run.Event == webhook_module.HookEventPullRequest || run.Event == webhook_module.HookEventPullRequestSync {
   157  		var payload api.PullRequestPayload
   158  		if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
   159  			return nil, err
   160  		}
   161  		return &payload, nil
   162  	}
   163  	return nil, fmt.Errorf("event %s is not a pull request event", run.Event)
   164  }
   165  
   166  func (run *ActionRun) IsSchedule() bool {
   167  	return run.ScheduleID > 0
   168  }
   169  
   170  func updateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error {
   171  	_, err := db.GetEngine(ctx).ID(repo.ID).
   172  		SetExpr("num_action_runs",
   173  			builder.Select("count(*)").From("action_run").
   174  				Where(builder.Eq{"repo_id": repo.ID}),
   175  		).
   176  		SetExpr("num_closed_action_runs",
   177  			builder.Select("count(*)").From("action_run").
   178  				Where(builder.Eq{
   179  					"repo_id": repo.ID,
   180  				}.And(
   181  					builder.In("status",
   182  						StatusSuccess,
   183  						StatusFailure,
   184  						StatusCancelled,
   185  						StatusSkipped,
   186  					),
   187  				),
   188  				),
   189  		).
   190  		Update(repo)
   191  	return err
   192  }
   193  
   194  // CancelPreviousJobs cancels all previous jobs of the same repository, reference, workflow, and event.
   195  // It's useful when a new run is triggered, and all previous runs needn't be continued anymore.
   196  func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) error {
   197  	// Find all runs in the specified repository, reference, and workflow with non-final status
   198  	runs, total, err := db.FindAndCount[ActionRun](ctx, FindRunOptions{
   199  		RepoID:       repoID,
   200  		Ref:          ref,
   201  		WorkflowID:   workflowID,
   202  		TriggerEvent: event,
   203  		Status:       []Status{StatusRunning, StatusWaiting, StatusBlocked},
   204  	})
   205  	if err != nil {
   206  		return err
   207  	}
   208  
   209  	// If there are no runs found, there's no need to proceed with cancellation, so return nil.
   210  	if total == 0 {
   211  		return nil
   212  	}
   213  
   214  	// Iterate over each found run and cancel its associated jobs.
   215  	for _, run := range runs {
   216  		// Find all jobs associated with the current run.
   217  		jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{
   218  			RunID: run.ID,
   219  		})
   220  		if err != nil {
   221  			return err
   222  		}
   223  
   224  		// Iterate over each job and attempt to cancel it.
   225  		for _, job := range jobs {
   226  			// Skip jobs that are already in a terminal state (completed, cancelled, etc.).
   227  			status := job.Status
   228  			if status.IsDone() {
   229  				continue
   230  			}
   231  
   232  			// If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it.
   233  			if job.TaskID == 0 {
   234  				job.Status = StatusCancelled
   235  				job.Stopped = timeutil.TimeStampNow()
   236  
   237  				// Update the job's status and stopped time in the database.
   238  				n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
   239  				if err != nil {
   240  					return err
   241  				}
   242  
   243  				// If the update affected 0 rows, it means the job has changed in the meantime, so we need to try again.
   244  				if n == 0 {
   245  					return fmt.Errorf("job has changed, try again")
   246  				}
   247  
   248  				// Continue with the next job.
   249  				continue
   250  			}
   251  
   252  			// If the job has an associated task, try to stop the task, effectively cancelling the job.
   253  			if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil {
   254  				return err
   255  			}
   256  		}
   257  	}
   258  
   259  	// Return nil to indicate successful cancellation of all running and waiting jobs.
   260  	return nil
   261  }
   262  
   263  // InsertRun inserts a run
   264  func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error {
   265  	ctx, committer, err := db.TxContext(ctx)
   266  	if err != nil {
   267  		return err
   268  	}
   269  	defer committer.Close()
   270  
   271  	index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
   272  	if err != nil {
   273  		return err
   274  	}
   275  	run.Index = index
   276  
   277  	if err := db.Insert(ctx, run); err != nil {
   278  		return err
   279  	}
   280  
   281  	if run.Repo == nil {
   282  		repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
   283  		if err != nil {
   284  			return err
   285  		}
   286  		run.Repo = repo
   287  	}
   288  
   289  	if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil {
   290  		return err
   291  	}
   292  
   293  	runJobs := make([]*ActionRunJob, 0, len(jobs))
   294  	var hasWaiting bool
   295  	for _, v := range jobs {
   296  		id, job := v.Job()
   297  		needs := job.Needs()
   298  		if err := v.SetJob(id, job.EraseNeeds()); err != nil {
   299  			return err
   300  		}
   301  		payload, _ := v.Marshal()
   302  		status := StatusWaiting
   303  		if len(needs) > 0 || run.NeedApproval {
   304  			status = StatusBlocked
   305  		} else {
   306  			hasWaiting = true
   307  		}
   308  		job.Name, _ = util.SplitStringAtByteN(job.Name, 255)
   309  		runJobs = append(runJobs, &ActionRunJob{
   310  			RunID:             run.ID,
   311  			RepoID:            run.RepoID,
   312  			OwnerID:           run.OwnerID,
   313  			CommitSHA:         run.CommitSHA,
   314  			IsForkPullRequest: run.IsForkPullRequest,
   315  			Name:              job.Name,
   316  			WorkflowPayload:   payload,
   317  			JobID:             id,
   318  			Needs:             needs,
   319  			RunsOn:            job.RunsOn(),
   320  			Status:            status,
   321  		})
   322  	}
   323  	if err := db.Insert(ctx, runJobs); err != nil {
   324  		return err
   325  	}
   326  
   327  	// if there is a job in the waiting status, increase tasks version.
   328  	if hasWaiting {
   329  		if err := IncreaseTaskVersion(ctx, run.OwnerID, run.RepoID); err != nil {
   330  			return err
   331  		}
   332  	}
   333  
   334  	return committer.Commit()
   335  }
   336  
   337  func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) {
   338  	var run ActionRun
   339  	has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
   340  	if err != nil {
   341  		return nil, err
   342  	} else if !has {
   343  		return nil, fmt.Errorf("run with id %d: %w", id, util.ErrNotExist)
   344  	}
   345  
   346  	return &run, nil
   347  }
   348  
   349  func GetRunByIndex(ctx context.Context, repoID, index int64) (*ActionRun, error) {
   350  	run := &ActionRun{
   351  		RepoID: repoID,
   352  		Index:  index,
   353  	}
   354  	has, err := db.GetEngine(ctx).Get(run)
   355  	if err != nil {
   356  		return nil, err
   357  	} else if !has {
   358  		return nil, fmt.Errorf("run with index %d %d: %w", repoID, index, util.ErrNotExist)
   359  	}
   360  
   361  	return run, nil
   362  }
   363  
   364  func GetWorkflowLatestRun(ctx context.Context, repoID int64, workflowFile, branch, event string) (*ActionRun, error) {
   365  	var run ActionRun
   366  	q := db.GetEngine(ctx).Where("repo_id=?", repoID).
   367  		And("ref = ?", branch).
   368  		And("workflow_id = ?", workflowFile)
   369  	if event != "" {
   370  		q.And("event = ?", event)
   371  	}
   372  	has, err := q.Desc("id").Get(&run)
   373  	if err != nil {
   374  		return nil, err
   375  	} else if !has {
   376  		return nil, util.NewNotExistErrorf("run with repo_id %d, ref %s, workflow_id %s", repoID, branch, workflowFile)
   377  	}
   378  	return &run, nil
   379  }
   380  
   381  // UpdateRun updates a run.
   382  // It requires the inputted run has Version set.
   383  // It will return error if the version is not matched (it means the run has been changed after loaded).
   384  func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
   385  	sess := db.GetEngine(ctx).ID(run.ID)
   386  	if len(cols) > 0 {
   387  		sess.Cols(cols...)
   388  	}
   389  	affected, err := sess.Update(run)
   390  	if err != nil {
   391  		return err
   392  	}
   393  	if affected == 0 {
   394  		return fmt.Errorf("run has changed")
   395  		// It's impossible that the run is not found, since Gitea never deletes runs.
   396  	}
   397  
   398  	if run.Status != 0 || slices.Contains(cols, "status") {
   399  		if run.RepoID == 0 {
   400  			run, err = GetRunByID(ctx, run.ID)
   401  			if err != nil {
   402  				return err
   403  			}
   404  		}
   405  		if run.Repo == nil {
   406  			repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
   407  			if err != nil {
   408  				return err
   409  			}
   410  			run.Repo = repo
   411  		}
   412  		if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil {
   413  			return err
   414  		}
   415  	}
   416  
   417  	return nil
   418  }
   419  
   420  type ActionRunIndex db.ResourceIndex