mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-28 09:15:18 +02:00
Several small fixes to the Actions runner management UI.
### Runner task list links to the job, not the workflow run
Relabeled the first column from "Run" to "Job"; it now shows the job ID
and links to the specific job (`/actions/runs/{runID}/jobs/{jobID}`).
Renamed locale key `task_list.run` to `task_list.job`.
### Missing "Disabled" translation
The runner list rendered a grey label via `actions.runners.disabled`,
but that key
did not exist in `locale_en-US.json`, so the raw key string leaked into
the UI.
Replaced `"actions.runners.disabled"` with `"disabled"`.
### Status column sorting ignored active vs idle
Sorting by status ordered purely on `last_online`, but the displayed
status is
computed from both `last_online` (offline) and `last_active` (idle vs
active).
As a result idle runners were interleaved with active ones. Sorting now
ranks by
the computed status (active → idle → offline). Disabled runners sink to
the bottom
of their status group (`is_disabled` as a secondary key), with
`last_online`/`id`
as stable tiebreakers so pagination stays deterministic.
### Status label colors
Active and idle both rendered green. Idle is now yellow, active green,
and
offline/unknown grey; the separate grey "Disabled" badge is unchanged.
This keeps
connectivity visible even for disabled runners (e.g. a disabled runner
still shows
whether it is idle or offline).
<img width="715" height="406" alt="image"
src="https://github.com/user-attachments/assets/9ef06aa8-a870-4de5-9d94-603a58186908"
/>
---------
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/actions-proto-go/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 `act_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()
|
|
}
|