code.gitea.io/gitea@v1.21.7/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           timeutil.TimeStamp
    50  	Stopped           timeutil.TimeStamp
    51  	Created           timeutil.TimeStamp `xorm:"created"`
    52  	Updated           timeutil.TimeStamp `xorm:"updated"`
    53  }
    54  
    55  func init() {
    56  	db.RegisterModel(new(ActionRun))
    57  	db.RegisterModel(new(ActionRunIndex))
    58  }
    59  
    60  func (run *ActionRun) HTMLURL() string {
    61  	if run.Repo == nil {
    62  		return ""
    63  	}
    64  	return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(), run.Index)
    65  }
    66  
    67  func (run *ActionRun) Link() string {
    68  	if run.Repo == nil {
    69  		return ""
    70  	}
    71  	return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.Index)
    72  }
    73  
    74  // RefLink return the url of run's ref
    75  func (run *ActionRun) RefLink() string {
    76  	refName := git.RefName(run.Ref)
    77  	if refName.IsPull() {
    78  		return run.Repo.Link() + "/pulls/" + refName.ShortName()
    79  	}
    80  	return git.RefURL(run.Repo.Link(), run.Ref)
    81  }
    82  
    83  // PrettyRef return #id for pull ref or ShortName for others
    84  func (run *ActionRun) PrettyRef() string {
    85  	refName := git.RefName(run.Ref)
    86  	if refName.IsPull() {
    87  		return "#" + strings.TrimSuffix(strings.TrimPrefix(run.Ref, git.PullPrefix), "/head")
    88  	}
    89  	return refName.ShortName()
    90  }
    91  
    92  // LoadAttributes load Repo TriggerUser if not loaded
    93  func (run *ActionRun) LoadAttributes(ctx context.Context) error {
    94  	if run == nil {
    95  		return nil
    96  	}
    97  
    98  	if run.Repo == nil {
    99  		repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
   100  		if err != nil {
   101  			return err
   102  		}
   103  		run.Repo = repo
   104  	}
   105  	if err := run.Repo.LoadAttributes(ctx); err != nil {
   106  		return err
   107  	}
   108  
   109  	if run.TriggerUser == nil {
   110  		u, err := user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
   111  		if err != nil {
   112  			return err
   113  		}
   114  		run.TriggerUser = u
   115  	}
   116  
   117  	return nil
   118  }
   119  
   120  func (run *ActionRun) Duration() time.Duration {
   121  	return calculateDuration(run.Started, run.Stopped, run.Status)
   122  }
   123  
   124  func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) {
   125  	if run.Event == webhook_module.HookEventPush {
   126  		var payload api.PushPayload
   127  		if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
   128  			return nil, err
   129  		}
   130  		return &payload, nil
   131  	}
   132  	return nil, fmt.Errorf("event %s is not a push event", run.Event)
   133  }
   134  
   135  func (run *ActionRun) GetPullRequestEventPayload() (*api.PullRequestPayload, error) {
   136  	if run.Event == webhook_module.HookEventPullRequest || run.Event == webhook_module.HookEventPullRequestSync {
   137  		var payload api.PullRequestPayload
   138  		if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
   139  			return nil, err
   140  		}
   141  		return &payload, nil
   142  	}
   143  	return nil, fmt.Errorf("event %s is not a pull request event", run.Event)
   144  }
   145  
   146  func updateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error {
   147  	_, err := db.GetEngine(ctx).ID(repo.ID).
   148  		SetExpr("num_action_runs",
   149  			builder.Select("count(*)").From("action_run").
   150  				Where(builder.Eq{"repo_id": repo.ID}),
   151  		).
   152  		SetExpr("num_closed_action_runs",
   153  			builder.Select("count(*)").From("action_run").
   154  				Where(builder.Eq{
   155  					"repo_id": repo.ID,
   156  				}.And(
   157  					builder.In("status",
   158  						StatusSuccess,
   159  						StatusFailure,
   160  						StatusCancelled,
   161  						StatusSkipped,
   162  					),
   163  				),
   164  				),
   165  		).
   166  		Update(repo)
   167  	return err
   168  }
   169  
   170  // CancelRunningJobs cancels all running and waiting jobs associated with a specific workflow.
   171  func CancelRunningJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) error {
   172  	// Find all runs in the specified repository, reference, and workflow with statuses 'Running' or 'Waiting'.
   173  	runs, total, err := FindRuns(ctx, FindRunOptions{
   174  		RepoID:       repoID,
   175  		Ref:          ref,
   176  		WorkflowID:   workflowID,
   177  		TriggerEvent: event,
   178  		Status:       []Status{StatusRunning, StatusWaiting},
   179  	})
   180  	if err != nil {
   181  		return err
   182  	}
   183  
   184  	// If there are no runs found, there's no need to proceed with cancellation, so return nil.
   185  	if total == 0 {
   186  		return nil
   187  	}
   188  
   189  	// Iterate over each found run and cancel its associated jobs.
   190  	for _, run := range runs {
   191  		// Find all jobs associated with the current run.
   192  		jobs, _, err := FindRunJobs(ctx, FindRunJobOptions{
   193  			RunID: run.ID,
   194  		})
   195  		if err != nil {
   196  			return err
   197  		}
   198  
   199  		// Iterate over each job and attempt to cancel it.
   200  		for _, job := range jobs {
   201  			// Skip jobs that are already in a terminal state (completed, cancelled, etc.).
   202  			status := job.Status
   203  			if status.IsDone() {
   204  				continue
   205  			}
   206  
   207  			// If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it.
   208  			if job.TaskID == 0 {
   209  				job.Status = StatusCancelled
   210  				job.Stopped = timeutil.TimeStampNow()
   211  
   212  				// Update the job's status and stopped time in the database.
   213  				n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
   214  				if err != nil {
   215  					return err
   216  				}
   217  
   218  				// If the update affected 0 rows, it means the job has changed in the meantime, so we need to try again.
   219  				if n == 0 {
   220  					return fmt.Errorf("job has changed, try again")
   221  				}
   222  
   223  				// Continue with the next job.
   224  				continue
   225  			}
   226  
   227  			// If the job has an associated task, try to stop the task, effectively cancelling the job.
   228  			if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil {
   229  				return err
   230  			}
   231  		}
   232  	}
   233  
   234  	// Return nil to indicate successful cancellation of all running and waiting jobs.
   235  	return nil
   236  }
   237  
   238  // InsertRun inserts a run
   239  func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error {
   240  	ctx, commiter, err := db.TxContext(ctx)
   241  	if err != nil {
   242  		return err
   243  	}
   244  	defer commiter.Close()
   245  
   246  	index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
   247  	if err != nil {
   248  		return err
   249  	}
   250  	run.Index = index
   251  
   252  	if err := db.Insert(ctx, run); err != nil {
   253  		return err
   254  	}
   255  
   256  	if run.Repo == nil {
   257  		repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
   258  		if err != nil {
   259  			return err
   260  		}
   261  		run.Repo = repo
   262  	}
   263  
   264  	if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil {
   265  		return err
   266  	}
   267  
   268  	runJobs := make([]*ActionRunJob, 0, len(jobs))
   269  	var hasWaiting bool
   270  	for _, v := range jobs {
   271  		id, job := v.Job()
   272  		needs := job.Needs()
   273  		if err := v.SetJob(id, job.EraseNeeds()); err != nil {
   274  			return err
   275  		}
   276  		payload, _ := v.Marshal()
   277  		status := StatusWaiting
   278  		if len(needs) > 0 || run.NeedApproval {
   279  			status = StatusBlocked
   280  		} else {
   281  			hasWaiting = true
   282  		}
   283  		job.Name, _ = util.SplitStringAtByteN(job.Name, 255)
   284  		runJobs = append(runJobs, &ActionRunJob{
   285  			RunID:             run.ID,
   286  			RepoID:            run.RepoID,
   287  			OwnerID:           run.OwnerID,
   288  			CommitSHA:         run.CommitSHA,
   289  			IsForkPullRequest: run.IsForkPullRequest,
   290  			Name:              job.Name,
   291  			WorkflowPayload:   payload,
   292  			JobID:             id,
   293  			Needs:             needs,
   294  			RunsOn:            job.RunsOn(),
   295  			Status:            status,
   296  		})
   297  	}
   298  	if err := db.Insert(ctx, runJobs); err != nil {
   299  		return err
   300  	}
   301  
   302  	// if there is a job in the waiting status, increase tasks version.
   303  	if hasWaiting {
   304  		if err := IncreaseTaskVersion(ctx, run.OwnerID, run.RepoID); err != nil {
   305  			return err
   306  		}
   307  	}
   308  
   309  	return commiter.Commit()
   310  }
   311  
   312  func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) {
   313  	var run ActionRun
   314  	has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
   315  	if err != nil {
   316  		return nil, err
   317  	} else if !has {
   318  		return nil, fmt.Errorf("run with id %d: %w", id, util.ErrNotExist)
   319  	}
   320  
   321  	return &run, nil
   322  }
   323  
   324  func GetRunByIndex(ctx context.Context, repoID, index int64) (*ActionRun, error) {
   325  	run := &ActionRun{
   326  		RepoID: repoID,
   327  		Index:  index,
   328  	}
   329  	has, err := db.GetEngine(ctx).Get(run)
   330  	if err != nil {
   331  		return nil, err
   332  	} else if !has {
   333  		return nil, fmt.Errorf("run with index %d %d: %w", repoID, index, util.ErrNotExist)
   334  	}
   335  
   336  	return run, nil
   337  }
   338  
   339  // UpdateRun updates a run.
   340  // It requires the inputted run has Version set.
   341  // It will return error if the version is not matched (it means the run has been changed after loaded).
   342  func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
   343  	sess := db.GetEngine(ctx).ID(run.ID)
   344  	if len(cols) > 0 {
   345  		sess.Cols(cols...)
   346  	}
   347  	affected, err := sess.Update(run)
   348  	if err != nil {
   349  		return err
   350  	}
   351  	if affected == 0 {
   352  		return fmt.Errorf("run has changed")
   353  		// It's impossible that the run is not found, since Gitea never deletes runs.
   354  	}
   355  
   356  	if run.Status != 0 || slices.Contains(cols, "status") {
   357  		if run.RepoID == 0 {
   358  			run, err = GetRunByID(ctx, run.ID)
   359  			if err != nil {
   360  				return err
   361  			}
   362  		}
   363  		if run.Repo == nil {
   364  			repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
   365  			if err != nil {
   366  				return err
   367  			}
   368  			run.Repo = repo
   369  		}
   370  		if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil {
   371  			return err
   372  		}
   373  	}
   374  
   375  	return nil
   376  }
   377  
   378  type ActionRunIndex db.ResourceIndex