mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-06 07:10:28 +00:00
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]>
406 lines
11 KiB
Go
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 := ®isterInputs{
|
|
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
|
|
}
|