code.gitea.io/gitea@v1.22.3/models/actions/task.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  	"crypto/subtle"
     9  	"fmt"
    10  	"time"
    11  
    12  	auth_model "code.gitea.io/gitea/models/auth"
    13  	"code.gitea.io/gitea/models/db"
    14  	"code.gitea.io/gitea/models/unit"
    15  	"code.gitea.io/gitea/modules/container"
    16  	"code.gitea.io/gitea/modules/log"
    17  	"code.gitea.io/gitea/modules/setting"
    18  	"code.gitea.io/gitea/modules/timeutil"
    19  	"code.gitea.io/gitea/modules/util"
    20  
    21  	runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
    22  	lru "github.com/hashicorp/golang-lru/v2"
    23  	"github.com/nektos/act/pkg/jobparser"
    24  	"google.golang.org/protobuf/types/known/timestamppb"
    25  	"xorm.io/builder"
    26  )
    27  
    28  // ActionTask represents a distribution of job
    29  type ActionTask struct {
    30  	ID       int64
    31  	JobID    int64
    32  	Job      *ActionRunJob     `xorm:"-"`
    33  	Steps    []*ActionTaskStep `xorm:"-"`
    34  	Attempt  int64
    35  	RunnerID int64              `xorm:"index"`
    36  	Status   Status             `xorm:"index"`
    37  	Started  timeutil.TimeStamp `xorm:"index"`
    38  	Stopped  timeutil.TimeStamp
    39  
    40  	RepoID            int64  `xorm:"index"`
    41  	OwnerID           int64  `xorm:"index"`
    42  	CommitSHA         string `xorm:"index"`
    43  	IsForkPullRequest bool
    44  
    45  	Token          string `xorm:"-"`
    46  	TokenHash      string `xorm:"UNIQUE"` // sha256 of token
    47  	TokenSalt      string
    48  	TokenLastEight string `xorm:"index token_last_eight"`
    49  
    50  	LogFilename  string     // file name of log
    51  	LogInStorage bool       // read log from database or from storage
    52  	LogLength    int64      // lines count
    53  	LogSize      int64      // blob size
    54  	LogIndexes   LogIndexes `xorm:"LONGBLOB"` // line number to offset
    55  	LogExpired   bool       // files that are too old will be deleted
    56  
    57  	Created timeutil.TimeStamp `xorm:"created"`
    58  	Updated timeutil.TimeStamp `xorm:"updated index"`
    59  }
    60  
    61  var successfulTokenTaskCache *lru.Cache[string, any]
    62  
    63  func init() {
    64  	db.RegisterModel(new(ActionTask), func() error {
    65  		if setting.SuccessfulTokensCacheSize > 0 {
    66  			var err error
    67  			successfulTokenTaskCache, err = lru.New[string, any](setting.SuccessfulTokensCacheSize)
    68  			if err != nil {
    69  				return fmt.Errorf("unable to allocate Task cache: %v", err)
    70  			}
    71  		} else {
    72  			successfulTokenTaskCache = nil
    73  		}
    74  		return nil
    75  	})
    76  }
    77  
    78  func (task *ActionTask) Duration() time.Duration {
    79  	return calculateDuration(task.Started, task.Stopped, task.Status)
    80  }
    81  
    82  func (task *ActionTask) IsStopped() bool {
    83  	return task.Stopped > 0
    84  }
    85  
    86  func (task *ActionTask) GetRunLink() string {
    87  	if task.Job == nil || task.Job.Run == nil {
    88  		return ""
    89  	}
    90  	return task.Job.Run.Link()
    91  }
    92  
    93  func (task *ActionTask) GetCommitLink() string {
    94  	if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
    95  		return ""
    96  	}
    97  	return task.Job.Run.Repo.CommitLink(task.CommitSHA)
    98  }
    99  
   100  func (task *ActionTask) GetRepoName() string {
   101  	if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
   102  		return ""
   103  	}
   104  	return task.Job.Run.Repo.FullName()
   105  }
   106  
   107  func (task *ActionTask) GetRepoLink() string {
   108  	if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
   109  		return ""
   110  	}
   111  	return task.Job.Run.Repo.Link()
   112  }
   113  
   114  func (task *ActionTask) LoadJob(ctx context.Context) error {
   115  	if task.Job == nil {
   116  		job, err := GetRunJobByID(ctx, task.JobID)
   117  		if err != nil {
   118  			return err
   119  		}
   120  		task.Job = job
   121  	}
   122  	return nil
   123  }
   124  
   125  // LoadAttributes load Job Steps if not loaded
   126  func (task *ActionTask) LoadAttributes(ctx context.Context) error {
   127  	if task == nil {
   128  		return nil
   129  	}
   130  	if err := task.LoadJob(ctx); err != nil {
   131  		return err
   132  	}
   133  
   134  	if err := task.Job.LoadAttributes(ctx); err != nil {
   135  		return err
   136  	}
   137  
   138  	if task.Steps == nil { // be careful, an empty slice (not nil) also means loaded
   139  		steps, err := GetTaskStepsByTaskID(ctx, task.ID)
   140  		if err != nil {
   141  			return err
   142  		}
   143  		task.Steps = steps
   144  	}
   145  
   146  	return nil
   147  }
   148  
   149  func (task *ActionTask) GenerateToken() (err error) {
   150  	task.Token, task.TokenSalt, task.TokenHash, task.TokenLastEight, err = generateSaltedToken()
   151  	return err
   152  }
   153  
   154  func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) {
   155  	var task ActionTask
   156  	has, err := db.GetEngine(ctx).Where("id=?", id).Get(&task)
   157  	if err != nil {
   158  		return nil, err
   159  	} else if !has {
   160  		return nil, fmt.Errorf("task with id %d: %w", id, util.ErrNotExist)
   161  	}
   162  
   163  	return &task, nil
   164  }
   165  
   166  func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) {
   167  	errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist)
   168  	if token == "" {
   169  		return nil, errNotExist
   170  	}
   171  	// A token is defined as being SHA1 sum these are 40 hexadecimal bytes long
   172  	if len(token) != 40 {
   173  		return nil, errNotExist
   174  	}
   175  	for _, x := range []byte(token) {
   176  		if x < '0' || (x > '9' && x < 'a') || x > 'f' {
   177  			return nil, errNotExist
   178  		}
   179  	}
   180  
   181  	lastEight := token[len(token)-8:]
   182  
   183  	if id := getTaskIDFromCache(token); id > 0 {
   184  		task := &ActionTask{
   185  			TokenLastEight: lastEight,
   186  		}
   187  		// Re-get the task from the db in case it has been deleted in the intervening period
   188  		has, err := db.GetEngine(ctx).ID(id).Get(task)
   189  		if err != nil {
   190  			return nil, err
   191  		}
   192  		if has {
   193  			return task, nil
   194  		}
   195  		successfulTokenTaskCache.Remove(token)
   196  	}
   197  
   198  	var tasks []*ActionTask
   199  	err := db.GetEngine(ctx).Where("token_last_eight = ? AND status = ?", lastEight, StatusRunning).Find(&tasks)
   200  	if err != nil {
   201  		return nil, err
   202  	} else if len(tasks) == 0 {
   203  		return nil, errNotExist
   204  	}
   205  
   206  	for _, t := range tasks {
   207  		tempHash := auth_model.HashToken(token, t.TokenSalt)
   208  		if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(tempHash)) == 1 {
   209  			if successfulTokenTaskCache != nil {
   210  				successfulTokenTaskCache.Add(token, t.ID)
   211  			}
   212  			return t, nil
   213  		}
   214  	}
   215  	return nil, errNotExist
   216  }
   217  
   218  func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
   219  	ctx, committer, err := db.TxContext(ctx)
   220  	if err != nil {
   221  		return nil, false, err
   222  	}
   223  	defer committer.Close()
   224  
   225  	e := db.GetEngine(ctx)
   226  
   227  	jobCond := builder.NewCond()
   228  	if runner.RepoID != 0 {
   229  		jobCond = builder.Eq{"repo_id": runner.RepoID}
   230  	} else if runner.OwnerID != 0 {
   231  		jobCond = builder.In("repo_id", builder.Select("`repository`.id").From("repository").
   232  			Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
   233  			Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
   234  	}
   235  	if jobCond.IsValid() {
   236  		jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond))
   237  	}
   238  
   239  	var jobs []*ActionRunJob
   240  	if err := e.Where("task_id=? AND status=?", 0, StatusWaiting).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil {
   241  		return nil, false, err
   242  	}
   243  
   244  	// TODO: a more efficient way to filter labels
   245  	var job *ActionRunJob
   246  	log.Trace("runner labels: %v", runner.AgentLabels)
   247  	for _, v := range jobs {
   248  		if isSubset(runner.AgentLabels, v.RunsOn) {
   249  			job = v
   250  			break
   251  		}
   252  	}
   253  	if job == nil {
   254  		return nil, false, nil
   255  	}
   256  	if err := job.LoadAttributes(ctx); err != nil {
   257  		return nil, false, err
   258  	}
   259  
   260  	now := timeutil.TimeStampNow()
   261  	job.Attempt++
   262  	job.Started = now
   263  	job.Status = StatusRunning
   264  
   265  	task := &ActionTask{
   266  		JobID:             job.ID,
   267  		Attempt:           job.Attempt,
   268  		RunnerID:          runner.ID,
   269  		Started:           now,
   270  		Status:            StatusRunning,
   271  		RepoID:            job.RepoID,
   272  		OwnerID:           job.OwnerID,
   273  		CommitSHA:         job.CommitSHA,
   274  		IsForkPullRequest: job.IsForkPullRequest,
   275  	}
   276  	if err := task.GenerateToken(); err != nil {
   277  		return nil, false, err
   278  	}
   279  
   280  	var workflowJob *jobparser.Job
   281  	if gots, err := jobparser.Parse(job.WorkflowPayload); err != nil {
   282  		return nil, false, fmt.Errorf("parse workflow of job %d: %w", job.ID, err)
   283  	} else if len(gots) != 1 {
   284  		return nil, false, fmt.Errorf("workflow of job %d: not single workflow", job.ID)
   285  	} else { //nolint:revive
   286  		_, workflowJob = gots[0].Job()
   287  	}
   288  
   289  	if _, err := e.Insert(task); err != nil {
   290  		return nil, false, err
   291  	}
   292  
   293  	task.LogFilename = logFileName(job.Run.Repo.FullName(), task.ID)
   294  	if err := UpdateTask(ctx, task, "log_filename"); err != nil {
   295  		return nil, false, err
   296  	}
   297  
   298  	if len(workflowJob.Steps) > 0 {
   299  		steps := make([]*ActionTaskStep, len(workflowJob.Steps))
   300  		for i, v := range workflowJob.Steps {
   301  			name, _ := util.SplitStringAtByteN(v.String(), 255)
   302  			steps[i] = &ActionTaskStep{
   303  				Name:   name,
   304  				TaskID: task.ID,
   305  				Index:  int64(i),
   306  				RepoID: task.RepoID,
   307  				Status: StatusWaiting,
   308  			}
   309  		}
   310  		if _, err := e.Insert(steps); err != nil {
   311  			return nil, false, err
   312  		}
   313  		task.Steps = steps
   314  	}
   315  
   316  	job.TaskID = task.ID
   317  	if n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}); err != nil {
   318  		return nil, false, err
   319  	} else if n != 1 {
   320  		return nil, false, nil
   321  	}
   322  
   323  	task.Job = job
   324  
   325  	if err := committer.Commit(); err != nil {
   326  		return nil, false, err
   327  	}
   328  
   329  	return task, true, nil
   330  }
   331  
   332  func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
   333  	sess := db.GetEngine(ctx).ID(task.ID)
   334  	if len(cols) > 0 {
   335  		sess.Cols(cols...)
   336  	}
   337  	_, err := sess.Update(task)
   338  	return err
   339  }
   340  
   341  // UpdateTaskByState updates the task by the state.
   342  // It will always update the task if the state is not final, even there is no change.
   343  // So it will update ActionTask.Updated to avoid the task being judged as a zombie task.
   344  func UpdateTaskByState(ctx context.Context, state *runnerv1.TaskState) (*ActionTask, error) {
   345  	stepStates := map[int64]*runnerv1.StepState{}
   346  	for _, v := range state.Steps {
   347  		stepStates[v.Id] = v
   348  	}
   349  
   350  	ctx, committer, err := db.TxContext(ctx)
   351  	if err != nil {
   352  		return nil, err
   353  	}
   354  	defer committer.Close()
   355  
   356  	e := db.GetEngine(ctx)
   357  
   358  	task := &ActionTask{}
   359  	if has, err := e.ID(state.Id).Get(task); err != nil {
   360  		return nil, err
   361  	} else if !has {
   362  		return nil, util.ErrNotExist
   363  	}
   364  
   365  	if task.Status.IsDone() {
   366  		// the state is final, do nothing
   367  		return task, nil
   368  	}
   369  
   370  	// state.Result is not unspecified means the task is finished
   371  	if state.Result != runnerv1.Result_RESULT_UNSPECIFIED {
   372  		task.Status = Status(state.Result)
   373  		task.Stopped = timeutil.TimeStamp(state.StoppedAt.AsTime().Unix())
   374  		if err := UpdateTask(ctx, task, "status", "stopped"); err != nil {
   375  			return nil, err
   376  		}
   377  		if _, err := UpdateRunJob(ctx, &ActionRunJob{
   378  			ID:      task.JobID,
   379  			Status:  task.Status,
   380  			Stopped: task.Stopped,
   381  		}, nil); err != nil {
   382  			return nil, err
   383  		}
   384  	} else {
   385  		// Force update ActionTask.Updated to avoid the task being judged as a zombie task
   386  		task.Updated = timeutil.TimeStampNow()
   387  		if err := UpdateTask(ctx, task, "updated"); err != nil {
   388  			return nil, err
   389  		}
   390  	}
   391  
   392  	if err := task.LoadAttributes(ctx); err != nil {
   393  		return nil, err
   394  	}
   395  
   396  	for _, step := range task.Steps {
   397  		var result runnerv1.Result
   398  		if v, ok := stepStates[step.Index]; ok {
   399  			result = v.Result
   400  			step.LogIndex = v.LogIndex
   401  			step.LogLength = v.LogLength
   402  			step.Started = convertTimestamp(v.StartedAt)
   403  			step.Stopped = convertTimestamp(v.StoppedAt)
   404  		}
   405  		if result != runnerv1.Result_RESULT_UNSPECIFIED {
   406  			step.Status = Status(result)
   407  		} else if step.Started != 0 {
   408  			step.Status = StatusRunning
   409  		}
   410  		if _, err := e.ID(step.ID).Update(step); err != nil {
   411  			return nil, err
   412  		}
   413  	}
   414  
   415  	if err := committer.Commit(); err != nil {
   416  		return nil, err
   417  	}
   418  
   419  	return task, nil
   420  }
   421  
   422  func StopTask(ctx context.Context, taskID int64, status Status) error {
   423  	if !status.IsDone() {
   424  		return fmt.Errorf("cannot stop task with status %v", status)
   425  	}
   426  	e := db.GetEngine(ctx)
   427  
   428  	task := &ActionTask{}
   429  	if has, err := e.ID(taskID).Get(task); err != nil {
   430  		return err
   431  	} else if !has {
   432  		return util.ErrNotExist
   433  	}
   434  	if task.Status.IsDone() {
   435  		return nil
   436  	}
   437  
   438  	now := timeutil.TimeStampNow()
   439  	task.Status = status
   440  	task.Stopped = now
   441  	if _, err := UpdateRunJob(ctx, &ActionRunJob{
   442  		ID:      task.JobID,
   443  		Status:  task.Status,
   444  		Stopped: task.Stopped,
   445  	}, nil); err != nil {
   446  		return err
   447  	}
   448  
   449  	if err := UpdateTask(ctx, task, "status", "stopped"); err != nil {
   450  		return err
   451  	}
   452  
   453  	if err := task.LoadAttributes(ctx); err != nil {
   454  		return err
   455  	}
   456  
   457  	for _, step := range task.Steps {
   458  		if !step.Status.IsDone() {
   459  			step.Status = status
   460  			if step.Started == 0 {
   461  				step.Started = now
   462  			}
   463  			step.Stopped = now
   464  		}
   465  		if _, err := e.ID(step.ID).Update(step); err != nil {
   466  			return err
   467  		}
   468  	}
   469  
   470  	return nil
   471  }
   472  
   473  func isSubset(set, subset []string) bool {
   474  	m := make(container.Set[string], len(set))
   475  	for _, v := range set {
   476  		m.Add(v)
   477  	}
   478  
   479  	for _, v := range subset {
   480  		if !m.Contains(v) {
   481  			return false
   482  		}
   483  	}
   484  	return true
   485  }
   486  
   487  func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp {
   488  	if timestamp.GetSeconds() == 0 && timestamp.GetNanos() == 0 {
   489  		return timeutil.TimeStamp(0)
   490  	}
   491  	return timeutil.TimeStamp(timestamp.AsTime().Unix())
   492  }
   493  
   494  func logFileName(repoFullName string, taskID int64) string {
   495  	return fmt.Sprintf("%s/%02x/%d.log", repoFullName, taskID%256, taskID)
   496  }
   497  
   498  func getTaskIDFromCache(token string) int64 {
   499  	if successfulTokenTaskCache == nil {
   500  		return 0
   501  	}
   502  	tInterface, ok := successfulTokenTaskCache.Get(token)
   503  	if !ok {
   504  		return 0
   505  	}
   506  	t, ok := tInterface.(int64)
   507  	if !ok {
   508  		return 0
   509  	}
   510  	return t
   511  }