Files
runner/internal/app/cmd/register.go
T
silverwind fe2a66dc25 fix: align runner behaviours (#1338)
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.

1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys

Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-23 09:18:24 +00:00

406 lines
11 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"bufio"
"context"
"errors"
"fmt"
"os"
goruntime "runtime"
"strings"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/internal/app/run"
"gitea.com/gitea/runner/internal/pkg/client"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/labels"
"gitea.com/gitea/runner/internal/pkg/lock"
"gitea.com/gitea/runner/internal/pkg/ver"
pingv1 "gitea.dev/actionslib/ping/v1"
runnerv1 "gitea.dev/actionslib/runner/v1"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// runRegister registers a runner to the server
func runRegister(ctx context.Context, regArgs *registerArgs, configFile *string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
logging.SetReportCaller(false)
isTerm := term.IsTerminal(int(os.Stdout.Fd()))
logging.SetTextFormat(logging.TextOptions{
DisableColors: !isTerm,
DisableTimestamp: true,
})
logging.SetLevel(logging.LevelDebug)
logging.Infof("Registering runner, arch=%s, os=%s, version=%s.",
goruntime.GOARCH, goruntime.GOOS, ver.Version())
// runner always needs root permission
if os.Getuid() != 0 {
// TODO: use a better way to check root permission
logging.Warnf("Runner in user-mode.")
}
if regArgs.NoInteractive {
return registerNoInteractive(ctx, *configFile, regArgs)
}
result := make(chan error, 1)
go func() { result <- registerInteractive(ctx, *configFile, regArgs) }()
select {
case err := <-result:
return err
case <-ctx.Done():
return nil
}
}
}
// registerArgs represents the arguments for register command
type registerArgs struct {
NoInteractive bool
InstanceAddr string
Token string
TokenFile string
RunnerName string
Labels string
Ephemeral bool
}
type registerStage int8
const (
StageUnknown registerStage = -1
StageOverwriteLocalConfig registerStage = iota + 1
StageInputInstance
StageInputToken
StageInputRunnerName
StageInputLabels
StageWaitingForRegistration
StageExit
)
const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
var defaultLabels = []string{
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
}
type registerInputs struct {
InstanceAddr string
Token string
RunnerName string
Labels []string
Ephemeral bool
}
func (r *registerInputs) validate() error {
if r.InstanceAddr == "" {
return errors.New("instance address is empty")
}
if r.Token == "" {
return errors.New("token is empty")
}
return validateLabels(r.Labels)
}
func validateLabels(ls []string) error {
for _, label := range ls {
if _, err := labels.Parse(label); err != nil {
return err
}
}
return nil
}
func (r *registerInputs) stageValue(stage registerStage) string {
switch stage {
case StageInputInstance:
return r.InstanceAddr
case StageInputToken:
return r.Token
case StageInputRunnerName:
return r.RunnerName
case StageInputLabels:
return strings.Join(r.Labels, ",")
}
return ""
}
func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *config.Config) registerStage {
if (stage == StageInputInstance || stage == StageInputToken) && value == "" {
return stage
}
// set hostname for runner name if empty
if stage == StageInputRunnerName && value == "" {
value, _ = os.Hostname()
}
switch stage {
case StageOverwriteLocalConfig:
if value == "Y" || value == "y" {
return StageInputInstance
}
return StageExit
case StageInputInstance:
r.InstanceAddr = value
return StageInputToken
case StageInputToken:
r.Token = value
return StageInputRunnerName
case StageInputRunnerName:
r.RunnerName = value
// if there are some labels configured in config file, skip input labels stage
if len(cfg.Runner.Labels) > 0 {
ls := make([]string, 0, len(cfg.Runner.Labels))
for _, l := range cfg.Runner.Labels {
_, err := labels.Parse(l)
if err != nil {
logging.WithError(err).Warnf("ignored invalid label %q", l)
continue
}
ls = append(ls, l)
}
if len(ls) == 0 {
logging.Warn("no valid labels configured in config file, runner may not be able to pick up jobs")
}
r.Labels = ls
return StageWaitingForRegistration
}
return StageInputLabels
case StageInputLabels:
r.Labels = defaultLabels
if value != "" {
r.Labels = strings.Split(value, ",")
}
if validateLabels(r.Labels) != nil {
logging.Info("Invalid labels, please input again, leave blank to use the default labels (for example, ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest)")
r.Labels = nil
return StageInputLabels
}
return StageWaitingForRegistration
}
return StageUnknown
}
func initInputs(regArgs *registerArgs) (*registerInputs, error) {
var token string
switch {
case regArgs.TokenFile != "":
tokenBytes, err := os.ReadFile(regArgs.TokenFile)
if err != nil {
return nil, fmt.Errorf("cannot read the token file: %s, %v", regArgs.TokenFile, err)
}
token = string(tokenBytes)
case regArgs.Token != "":
token = regArgs.Token
default:
token = os.Getenv(registerTokenEnvVar)
if token == "" && regArgs.NoInteractive {
return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar)
}
}
inputs := &registerInputs{
InstanceAddr: strings.TrimSpace(regArgs.InstanceAddr),
Token: strings.TrimSpace(token),
RunnerName: strings.TrimSpace(regArgs.RunnerName),
Ephemeral: regArgs.Ephemeral,
}
regArgs.Labels = strings.TrimSpace(regArgs.Labels)
// command line flag.
if regArgs.Labels != "" {
inputs.Labels = strings.Split(regArgs.Labels, ",")
}
return inputs, nil
}
func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
var (
reader = bufio.NewReader(os.Stdin)
stage = StageInputInstance
)
cfg, err := config.LoadDefault(configFile)
if err != nil {
return fmt.Errorf("failed to load config: %v", err)
}
if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() {
stage = StageOverwriteLocalConfig
}
inputs, err := initInputs(regArgs)
if err != nil {
return err
}
for {
cmdString := inputs.stageValue(stage)
if cmdString == "" {
printStageHelp(stage)
var err error
cmdString, err = reader.ReadString('\n')
if err != nil {
return err
}
}
stage = inputs.assignToNext(stage, strings.TrimSpace(cmdString), cfg)
if stage == StageWaitingForRegistration {
logging.Infof("Registering runner, name=%s, instance=%s, labels=%v.", inputs.RunnerName, inputs.InstanceAddr, inputs.Labels)
if err := doRegister(ctx, cfg, inputs); err != nil {
return fmt.Errorf("failed to register runner: %w", err)
}
logging.Infof("Runner registered successfully.")
return nil
}
if stage == StageExit {
return nil
}
if stage <= StageUnknown {
logging.Errorf("Invalid input, please re-run act command.")
return nil
}
}
}
func printStageHelp(stage registerStage) {
switch stage {
case StageOverwriteLocalConfig:
logging.Info("Runner is already registered, overwrite local config? [y/N]")
case StageInputInstance:
logging.Info("Enter the Gitea instance URL (for example, https://gitea.com/):")
case StageInputToken:
logging.Info("Enter the runner token:")
case StageInputRunnerName:
hostname, _ := os.Hostname()
logging.Infof("Enter the runner name (if set empty, use hostname: %s):\n", hostname)
case StageInputLabels:
logging.Info("Enter the runner labels, leave blank to use the default labels (comma-separated, for example, ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest):")
case StageWaitingForRegistration:
logging.Info("Waiting for registration...")
}
}
func registerNoInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
cfg, err := config.LoadDefault(configFile)
if err != nil {
return err
}
inputs, err := initInputs(regArgs)
if err != nil {
return err
}
// specify labels in config file.
if len(cfg.Runner.Labels) > 0 {
if regArgs.Labels != "" {
logging.Warn("Labels from command will be ignored, use labels defined in config file.")
}
inputs.Labels = cfg.Runner.Labels
}
if len(inputs.Labels) == 0 {
inputs.Labels = defaultLabels
}
if inputs.RunnerName == "" {
inputs.RunnerName, _ = os.Hostname()
logging.Infof("Runner name is empty, use hostname '%s'.", inputs.RunnerName)
}
if err := inputs.validate(); err != nil {
logging.WithError(err).Errorf("Invalid input, please re-run act command.")
return err
}
if err := doRegister(ctx, cfg, inputs); err != nil {
return fmt.Errorf("failed to register runner: %w", err)
}
logging.Infof("Runner registered successfully.")
return nil
}
func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs) error {
// Refuse to rewrite the runner file while another process is using it.
releaseLock, err := lock.TryLock(cfg.Runner.File)
switch {
case errors.Is(err, lock.ErrLocked):
return fmt.Errorf("another process is already using %q; stop it before re-registering", cfg.Runner.File)
case err != nil:
// Best-effort guard: if the lock file can't be created, warn and
// register anyway; writing the runner file will surface any real
// permission problem with a clearer error.
logging.Warnf("could not lock runner file %q, continuing without the single-process guard: %v", cfg.Runner.File, err)
default:
defer func() { _ = releaseLock() }()
}
// initial http client
cli := client.New(
inputs.InstanceAddr,
cfg.Runner.TLSConfig(),
"",
"",
config.RequestTimeout,
cfg.Runner.ExtraHeaders,
)
if _, err := cli.Ping(ctx, &pingv1.PingRequest{
Data: inputs.RunnerName,
}); err != nil {
return fmt.Errorf("cannot ping the Gitea instance server: %w", err)
}
logging.Debug("Successfully pinged the Gitea instance server")
reg := &config.Registration{
Name: inputs.RunnerName,
Token: inputs.Token,
Address: inputs.InstanceAddr,
Labels: inputs.Labels,
Ephemeral: inputs.Ephemeral,
}
ls := make([]string, len(reg.Labels))
for i, v := range reg.Labels {
l, err := labels.Parse(v)
if err != nil {
return fmt.Errorf("failed to parse label %q: %w", v, err)
}
ls[i] = l.Name
}
// register new runner.
resp, err := cli.Register(ctx, &runnerv1.RegisterRequest{
Name: reg.Name,
Token: reg.Token,
Version: ver.Version(),
Labels: ls,
Ephemeral: reg.Ephemeral,
Capabilities: run.RunnerCapabilities(),
})
if err != nil {
logging.WithError(err).Error("poller: cannot register new runner")
return err
}
reg.ID = resp.Runner.Id
reg.UUID = resp.Runner.Uuid
reg.Name = resp.Runner.Name
reg.Token = resp.Runner.Token
if inputs.Ephemeral != resp.Runner.Ephemeral {
// TODO we cannot remove the configuration via runner api, if we return an error here we just fill the database
logging.Error("poller: cannot register new runner as ephemeral upgrade Gitea to gain security, run-once will be used automatically")
}
if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil {
return fmt.Errorf("failed to save runner config: %w", err)
}
return nil
}