mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-09 05:38:54 +09:00
2087d4a1a5
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>
458 lines
15 KiB
Go
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()
|
|
}
|