Files
gitea/models/actions/runner.go
T
Lunny Xiao 2087d4a1a5 refactor: use the shared workflow model from actionslib (#38768)
Pairs with https://gitea.com/gitea/runner/pulls/1143.

Gitea depends on `gitea.com/gitea/runner` for exactly two packages:
`act/model` and `act/exprparser`, the workflow model and the expression
evaluator it needs to parse workflows and to build the task payload the
runner consumes. Pulling the whole runner module in for that is heavy
and puts shared code in the repository of one of the two consumers.

Both packages now live in `gitea.dev/actionslib` (`pkg/model`,
`pkg/exprparser`), the module Gitea and the runner already share for the
runner API, so the dependency on the runner repository is dropped here.

### Changes

- `gitea.com/gitea/runner/act/model` ->
`gitea.dev/actionslib/pkg/model`, `.../act/exprparser` ->
`gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only).
- `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid`
/ `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol`
instead of repeating the literals the runner also has.
- `go.mod`: `gitea.com/gitea/runner` removed.

---------

Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-08-07 20:56:40 -07:00

458 lines
15 KiB
Go

// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"strings"
"time"
runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/shared/types"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// ActionRunner represents runner machines
//
// It can be:
// 1. global runner, OwnerID is 0 and RepoID is 0
// 2. org/user level runner, OwnerID is org/user ID and RepoID is 0
// 3. repo level runner, OwnerID is 0 and RepoID is repo ID
//
// Please note that it's not acceptable to have both OwnerID and RepoID to be non-zero,
// or it will be complicated to find runners belonging to a specific owner.
// For example, conditions like `OwnerID = 1` will also return runner {OwnerID: 1, RepoID: 1},
// but it's a repo level runner, not an org/user level runner.
// To avoid this, make it clear with {OwnerID: 0, RepoID: 1} for repo level runners.
type ActionRunner struct {
ID int64
UUID string `xorm:"CHAR(36) UNIQUE"`
Name string `xorm:"VARCHAR(255)"`
Version string `xorm:"VARCHAR(64)"`
OwnerID int64 `xorm:"index"`
Owner *user_model.User `xorm:"-"`
RepoID int64 `xorm:"index"`
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 labels defined in state file (default: .runner file) of the `runner`
AgentLabels []string `xorm:"TEXT"`
// Store if this is a runner that only ever get one single job assigned
Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"`
// Store if this runner is disabled and should not pick up new jobs
IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"`
// Store if this runner supports the StatusCancelling flow
HasCancellingSupport bool `xorm:"has_cancelling_support NOT NULL DEFAULT false"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
Deleted timeutil.TimeStamp `xorm:"deleted"`
}
const (
RunnerOfflineTime = time.Minute
RunnerIdleTime = 10 * time.Second
// RunnerHeartbeatInterval is how often last_online is persisted on poll.
// Must stay well below RunnerOfflineTime so runners don't flap to offline.
RunnerHeartbeatInterval = 30 * time.Second
// RunnerActiveInterval is how often last_active is persisted while a runner
// streams task updates and logs. Must stay well below RunnerIdleTime so a
// busy runner keeps showing ACTIVE without a DB write on every RPC.
RunnerActiveInterval = 5 * time.Second
)
// ShouldPersistLastOnline reports whether last_online is stale enough to be
// worth writing back. Avoids a DB write on every runner poll.
func ShouldPersistLastOnline(last timeutil.TimeStamp, now time.Time) bool {
return now.Sub(last.AsTime()) >= RunnerHeartbeatInterval
}
// ShouldPersistLastActive reports whether last_active is stale enough to be
// worth writing back. Avoids a DB write on every UpdateTask/UpdateLog RPC while
// a runner is actively streaming logs.
func ShouldPersistLastActive(last timeutil.TimeStamp, now time.Time) bool {
return now.Sub(last.AsTime()) >= RunnerActiveInterval
}
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
func (r *ActionRunner) BelongsToOwnerName() string {
if r.RepoID != 0 {
return r.Repo.FullName()
}
if r.OwnerID != 0 {
return r.Owner.Name
}
return ""
}
func (r *ActionRunner) BelongsToOwnerType() types.OwnerType {
if r.RepoID != 0 {
return types.OwnerTypeRepository
}
if r.OwnerID != 0 {
switch r.Owner.Type {
case user_model.UserTypeOrganization:
return types.OwnerTypeOrganization
case user_model.UserTypeIndividual:
return types.OwnerTypeIndividual
}
}
return types.OwnerTypeSystemGlobal
}
// if the logic here changed, you should also modify FindRunnerOptions.ToCond
func (r *ActionRunner) Status() runnerv1.RunnerStatus {
if time.Since(r.LastOnline.AsTime()) > RunnerOfflineTime {
return runnerv1.RunnerStatus_RUNNER_STATUS_OFFLINE
}
if time.Since(r.LastActive.AsTime()) > RunnerIdleTime {
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.TrString("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
}
// EditableInContext checks if the runner is editable by the "context" owner/repo
// ownerID == 0 and repoID == 0 means "admin" context, any runner including global runners could be edited
// ownerID == 0 and repoID != 0 means "repo" context, any runner belonging to the given repo could be edited
// ownerID != 0 and repoID == 0 means "owner(org/user)" context, any runner belonging to the given user/org could be edited
// ownerID != 0 and repoID != 0 means "owner" OR "repo" context, legacy behavior, but we should forbid using it
func (r *ActionRunner) EditableInContext(ownerID, repoID int64) bool {
if ownerID != 0 && repoID != 0 {
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
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) GenerateAndFillToken() {
r.Token, r.TokenSalt, r.TokenHash, _ = generateSaltedToken()
}
// CanMatchLabels checks whether the runner's labels can match a job's "runs-on"
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on
func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool {
runnerLabelSet := container.SetOf(r.AgentLabels...)
return runnerLabelSet.Contains(jobRunsOn...) // match all labels
}
func init() {
db.RegisterModel(&ActionRunner{})
}
// FindRunnerOptions
// ownerID == 0 and repoID == 0 means any runner including global runners
// repoID != 0 and WithAvailable == false means any runner for the given repo
// repoID != 0 and WithAvailable == true means any runner for the given repo, parent user/org, and global runners
// ownerID != 0 and repoID == 0 and WithAvailable == false means any runner for the given user/org
// ownerID != 0 and repoID == 0 and WithAvailable == true means any runner for the given user/org and global runners
type FindRunnerOptions struct {
db.ListOptions
IDs []int64
RepoID int64
OwnerID int64 // it will be ignored if RepoID is set
Sort string
Filter string
IsOnline optional.Option[bool]
IsDisabled optional.Option[bool]
WithAvailable bool // not only runners belong to, but also runners can be used
}
func (opts FindRunnerOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if len(opts.IDs) > 0 {
if len(opts.IDs) == 1 {
cond = cond.And(builder.Eq{"id": opts.IDs[0]})
} else {
cond = cond.And(builder.In("id", opts.IDs))
}
}
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)
} else if opts.OwnerID > 0 { // OwnerID is ignored if RepoID is set
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})
}
if opts.IsOnline.Has() {
if opts.IsOnline.Value() {
cond = cond.And(builder.Gt{"last_online": time.Now().Add(-RunnerOfflineTime).Unix()})
} else {
cond = cond.And(builder.Lte{"last_online": time.Now().Add(-RunnerOfflineTime).Unix()})
}
}
if opts.IsDisabled.Has() {
cond = cond.And(builder.Eq{"is_disabled": opts.IsDisabled.Value()})
}
return cond
}
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
// The thresholds are evaluated against the current time, mirroring ToConds, so
// sorting by status groups active and idle runners instead of interleaving them
// by raw last_online.
func runnerStatusOrderExpr() string {
now := time.Now()
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
idleThreshold := now.Add(-RunnerIdleTime).Unix()
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
}
func (opts FindRunnerOptions) ToOrders() string {
// A unique tiebreaker (id) is appended so that runners sharing the same
// status, last_online or name keep a deterministic order across paginated
// queries, otherwise the same runner may appear on more than one page.
statusRank := runnerStatusOrderExpr()
switch opts.Sort {
case "online":
// Rank by computed status first so idle runners are not interleaved with
// active ones; disabled runners sink to the bottom of their status group
// (is_disabled ASC), then last_online breaks ties within a group.
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
case "offline":
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
case "alphabetically":
return "name ASC, id ASC"
case "reversealphabetically":
return "name DESC, id ASC"
case "newest":
return "id DESC"
case "oldest":
return "id ASC"
}
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
}
// 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)
r.Name = util.EllipsisDisplayString(r.Name, 255)
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
}
func SetRunnerDisabled(ctx context.Context, runner *ActionRunner, isDisabled bool) error {
if runner.IsDisabled == isDisabled {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
runner.IsDisabled = isDisabled
if err := UpdateRunner(ctx, runner, "is_disabled"); err != nil {
return err
}
return IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID)
})
}
// 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.DeleteByID[ActionRunner](ctx, id)
return err
}
// DeleteEphemeralRunner deletes a ephemeral runner by given ID.
func DeleteEphemeralRunner(ctx context.Context, id int64) error {
runner, err := GetRunnerByID(ctx, id)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
return nil
}
return err
}
if !runner.Ephemeral {
return nil
}
_, err = db.DeleteByID[ActionRunner](ctx, id)
return err
}
// CreateRunner creates new runner.
func CreateRunner(ctx context.Context, t *ActionRunner) error {
if t.OwnerID != 0 && t.RepoID != 0 {
// It's trying to create a runner that belongs to a repository, but OwnerID has been set accidentally.
// Remove OwnerID to avoid confusion; it's not worth returning an error here.
t.OwnerID = 0
}
t.Name = util.EllipsisDisplayString(t.Name, 255)
return db.Insert(ctx, t)
}
func CountRunnersWithoutBelongingOwner(ctx context.Context) (int64, error) {
// Only affect action runners were a owner ID is set, as actions runners
// could also be created on a repository.
return db.GetEngine(ctx).Table("action_runner").
Join("LEFT", "`user`", "`action_runner`.owner_id = `user`.id").
Where("`action_runner`.owner_id != ?", 0).
And(builder.IsNull{"`user`.id"}).
Count(new(ActionRunner))
}
func FixRunnersWithoutBelongingOwner(ctx context.Context) (int64, error) {
subQuery := builder.Select("`action_runner`.id").
From("`action_runner`").
Join("LEFT", "`user`", "`action_runner`.owner_id = `user`.id").
Where(builder.Neq{"`action_runner`.owner_id": 0}).
And(builder.IsNull{"`user`.id"})
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
res, err := db.GetEngine(ctx).Exec(b)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func CountRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Table("action_runner").
Join("LEFT", "`repository`", "`action_runner`.repo_id = `repository`.id").
Where("`action_runner`.repo_id != ?", 0).
And(builder.IsNull{"`repository`.id"}).
Count(new(ActionRunner))
}
func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
subQuery := builder.Select("`action_runner`.id").
From("`action_runner`").
Join("LEFT", "`repository`", "`action_runner`.repo_id = `repository`.id").
Where(builder.Neq{"`action_runner`.repo_id": 0}).
And(builder.IsNull{"`repository`.id"})
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
res, err := db.GetEngine(ctx).Exec(b)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func CountWrongRepoLevelRunners(ctx context.Context) (int64, error) {
var result int64
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `action_runner` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
return result, err
}
func UpdateWrongRepoLevelRunners(ctx context.Context) (int64, error) {
result, err := db.GetEngine(ctx).Exec("UPDATE `action_runner` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
if err != nil {
return 0, err
}
return result.RowsAffected()
}