mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 06:45:44 +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]>
975 lines
31 KiB
Go
975 lines
31 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package report
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common/logging"
|
|
"gitea.com/gitea/runner/act/runner"
|
|
"gitea.com/gitea/runner/internal/pkg/client"
|
|
"gitea.com/gitea/runner/internal/pkg/config"
|
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
|
|
|
"connectrpc.com/connect/v2"
|
|
"gitea.dev/actionslib/pkg/model"
|
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
// errOutputsNotSent travels the same return path as transport failures but is not one.
|
|
var errOutputsNotSent = errors.New("there are still outputs that have not been sent")
|
|
|
|
// Size limits for the outputs reported to the server.
|
|
const (
|
|
maxOutputKeyLen = 255
|
|
maxOutputValueLen = 1024 * 1024 // 1 MiB
|
|
)
|
|
|
|
// jobOutput is a job output on its way to the server, sent once the server has acknowledged it.
|
|
type jobOutput struct {
|
|
value string
|
|
sent bool
|
|
}
|
|
|
|
type Reporter struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
closed bool
|
|
client client.Client
|
|
clientM sync.Mutex
|
|
|
|
logOffset int
|
|
logRows []*runnerv1.LogRow
|
|
logReplacer *strings.Replacer
|
|
oldnew []string
|
|
|
|
// lastLogBufferRows is the last value written to the ReportLogBufferRows
|
|
// gauge; guarded by clientM (the same lock held around each ReportLog call)
|
|
// so the gauge skips no-op Set calls when the buffer size is unchanged.
|
|
lastLogBufferRows int
|
|
|
|
state *runnerv1.TaskState
|
|
stateChanged bool
|
|
// The steps in state.ReportedSteps by stage and workflow step number, the step the next log row
|
|
// without a step of its own belongs to, and the workflow steps. Guarded by stateMu.
|
|
reportedSteps map[reportedStepKey]*runnerv1.StepState
|
|
currentReportedStep *runnerv1.StepState
|
|
stepModels []*model.Step
|
|
// reportFailing keeps an outage to one log line at each end.
|
|
reportFailing map[string]bool
|
|
// serverResult is what the server decided, e.g. the zombie reaper failing
|
|
// the task. Guarded by stateMu.
|
|
serverResult runnerv1.Result
|
|
stateMu sync.RWMutex
|
|
outputsMu sync.Mutex
|
|
outputs map[string]jobOutput
|
|
daemon chan struct{}
|
|
heartbeatStop chan struct{}
|
|
heartbeatStopOnce sync.Once
|
|
|
|
lastReportedAtNanos int64 // guarded by clientM
|
|
|
|
// Adaptive batching control
|
|
logReportInterval time.Duration
|
|
logReportMaxLatency time.Duration
|
|
logBatchSize int
|
|
logReportLevel logging.Level
|
|
stateReportInterval time.Duration
|
|
// closeTimeout bounds each RPC attempt in the final flush, on a context
|
|
// detached from r.ctx so a server cancel can't abort the acknowledgement.
|
|
closeTimeout time.Duration
|
|
// daemonWait bounds how long Close waits for the daemon loop to acknowledge.
|
|
daemonWait time.Duration
|
|
|
|
// Event notification channels (non-blocking, buffered 1)
|
|
logNotify chan struct{} // signal: new log rows arrived
|
|
stateNotify chan struct{} // signal: step transition (start/stop)
|
|
|
|
jobLog *jobLog // this task's rows on the runner's own disk, nil when log.job.dir is unset
|
|
}
|
|
|
|
// extraMasks are values known before the job starts that are not among its secrets, such as
|
|
// the password in the runner's proxy URL.
|
|
func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, cfg *config.Config, extraMasks ...string) *Reporter {
|
|
var oldnew []string
|
|
for _, v := range extraMasks {
|
|
oldnew = runner.AppendSecretMasker(oldnew, v)
|
|
}
|
|
for _, key := range []string{"token", "gitea_runtime_token", "actions_id_token_request_token"} {
|
|
oldnew = runner.AppendSecretMasker(oldnew, task.Context.Fields[key].GetStringValue())
|
|
}
|
|
oldnew = runner.AppendSecretMaskers(oldnew, task.Secrets)
|
|
|
|
rv := &Reporter{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
client: client,
|
|
oldnew: oldnew,
|
|
logReplacer: runner.NewSecretReplacer(oldnew),
|
|
logReportInterval: cfg.Runner.LogReportInterval,
|
|
logReportMaxLatency: cfg.Runner.LogReportMaxLatency,
|
|
logBatchSize: cfg.Runner.LogReportBatchSize,
|
|
logReportLevel: logging.GetLevel(),
|
|
stateReportInterval: cfg.Runner.StateReportInterval,
|
|
closeTimeout: cfg.Runner.ReportCloseTimeout,
|
|
logNotify: make(chan struct{}, 1),
|
|
stateNotify: make(chan struct{}, 1),
|
|
state: &runnerv1.TaskState{
|
|
Id: task.Id,
|
|
},
|
|
reportFailing: map[string]bool{},
|
|
daemon: make(chan struct{}),
|
|
heartbeatStop: make(chan struct{}),
|
|
jobLog: openJobLog(cfg.Log.Job, task.Id, time.Now()),
|
|
}
|
|
|
|
if level, err := logging.ParseLevel(cfg.Runner.LogReportLevel); err == nil { // unset keeps log.level
|
|
rv.logReportLevel = level
|
|
}
|
|
|
|
rv.daemonWait = 6 * rv.effectiveCloseTimeout()
|
|
|
|
registerGlobalMasks(rv)
|
|
|
|
return rv
|
|
}
|
|
|
|
// Result returns the final job result. Safe to call after Close() returns.
|
|
func (r *Reporter) Result() runnerv1.Result {
|
|
r.stateMu.RLock()
|
|
defer r.stateMu.RUnlock()
|
|
return r.state.Result
|
|
}
|
|
|
|
// ResetSteps sets the steps of the workflow job. They are reported both by their index in the
|
|
// workflow file, for Gitea versions deriving the steps from the file, and as the steps the runner
|
|
// runs, together with the setup, pre, post and cleanup steps, for Gitea versions that show those.
|
|
func (r *Reporter) ResetSteps(steps []*model.Step) {
|
|
r.stateMu.Lock()
|
|
defer r.stateMu.Unlock()
|
|
for i := range steps {
|
|
r.state.Steps = append(r.state.Steps, &runnerv1.StepState{
|
|
Id: int64(i),
|
|
})
|
|
}
|
|
if len(steps) == 0 {
|
|
return // a reusable workflow caller, whose called jobs log their own steps through it
|
|
}
|
|
r.stepModels = steps
|
|
r.reportedSteps = map[reportedStepKey]*runnerv1.StepState{}
|
|
setup := r.addReportedStep(len(r.state.ReportedSteps), runnerv1.StepStage_STEP_STAGE_SETUP, 0, "Set up job")
|
|
setup.StartedAt = cmp.Or(r.state.StartedAt, timestamppb.Now())
|
|
setup.LogLength = int64(r.logOffset + len(r.logRows)) // the runner's own lines logged before the job was parsed
|
|
r.currentReportedStep = setup
|
|
for i, step := range steps {
|
|
r.addReportedStep(len(r.state.ReportedSteps), runnerv1.StepStage_STEP_STAGE_MAIN, i, r.mask(stepDisplayName(step, "")))
|
|
}
|
|
r.addReportedStep(len(r.state.ReportedSteps), runnerv1.StepStage_STEP_STAGE_CLEANUP, 0, "Complete job")
|
|
}
|
|
|
|
type reportedStepKey struct {
|
|
stage runnerv1.StepStage
|
|
number int
|
|
}
|
|
|
|
// stepDisplayName names a workflow step like Gitea and GitHub do, prefixing an unnamed one with "Run".
|
|
// evaluated is step.String() with its expressions evaluated, empty while the step has not run yet.
|
|
func stepDisplayName(step *model.Step, evaluated string) string {
|
|
if step.Name != "" {
|
|
return cmp.Or(evaluated, step.Name)
|
|
}
|
|
first, _, _ := strings.Cut(strings.TrimSpace(cmp.Or(evaluated, step.String())), "\n")
|
|
return "Run " + cmp.Or(strings.TrimSpace(first), step.String())
|
|
}
|
|
|
|
// addReportedStep inserts a step at pos of the reported steps. Caller holds stateMu.
|
|
func (r *Reporter) addReportedStep(pos int, stage runnerv1.StepStage, number int, name string) *runnerv1.StepState {
|
|
step := &runnerv1.StepState{Name: name, Stage: stage, Number: int64(number)}
|
|
r.state.ReportedSteps = slices.Insert(r.state.ReportedSteps, pos, step)
|
|
for i, v := range r.state.ReportedSteps[pos:] {
|
|
v.Id = int64(pos + i)
|
|
}
|
|
r.reportedSteps[reportedStepKey{stage, number}] = step
|
|
return step
|
|
}
|
|
|
|
// reportedStepOf returns the reported step an entry belongs to, adding a pre or post step the
|
|
// first time it runs, and whether the entry names that step itself. Caller holds stateMu.
|
|
func (r *Reporter) reportedStepOf(entry logging.Record) (*runnerv1.StepState, bool) {
|
|
if r.reportedSteps == nil {
|
|
return nil, false
|
|
}
|
|
stage, _ := recordAttr[string](entry, "stage")
|
|
number, ok := recordAttr[int](entry, "stepNumber")
|
|
if stage == runner.StageComplete {
|
|
return r.reportedSteps[reportedStepKey{stage: runnerv1.StepStage_STEP_STAGE_CLEANUP}], false
|
|
}
|
|
if !ok || number < 0 || number >= len(r.stepModels) {
|
|
return r.currentReportedStep, false
|
|
}
|
|
var key reportedStepKey
|
|
switch stage {
|
|
case runner.StagePre:
|
|
key = reportedStepKey{runnerv1.StepStage_STEP_STAGE_PRE, number}
|
|
case runner.StageMain:
|
|
key = reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}
|
|
case runner.StagePost:
|
|
key = reportedStepKey{runnerv1.StepStage_STEP_STAGE_POST, number}
|
|
default:
|
|
return r.currentReportedStep, false
|
|
}
|
|
main := r.reportedSteps[reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}]
|
|
if evaluated, _ := recordAttr[string](entry, "step"); evaluated != "" {
|
|
// evaluated as GitHub shows it, and masked as an expression can name a secret
|
|
main.Name = r.mask(stepDisplayName(r.stepModels[number], evaluated))
|
|
}
|
|
if step, ok := r.reportedSteps[key]; ok {
|
|
return step, true
|
|
}
|
|
if started, _ := recordAttr[bool](entry, runner.StepStartedField); !started {
|
|
return r.currentReportedStep, false // a pre or post step that does not run is not shown
|
|
}
|
|
mainName := main.Name
|
|
if stage == runner.StagePre {
|
|
// pre steps run in the order of the workflow steps, so each goes right before the main steps
|
|
pos := slices.IndexFunc(r.state.ReportedSteps, func(v *runnerv1.StepState) bool { return v.Stage == runnerv1.StepStage_STEP_STAGE_MAIN })
|
|
return r.addReportedStep(pos, key.stage, number, "Pre "+mainName), true
|
|
}
|
|
return r.addReportedStep(len(r.state.ReportedSteps)-1, key.stage, number, "Post "+mainName), true
|
|
}
|
|
|
|
// recordAttr returns the attribute key of rec if it is a T.
|
|
func recordAttr[T any](rec logging.Record, key string) (T, bool) {
|
|
v, _ := rec.Attr(key)
|
|
t, ok := v.(T)
|
|
return t, ok
|
|
}
|
|
|
|
// enterReportedStep makes step the one log rows without a step of their own belong to, finishing
|
|
// the setup step once another step starts. It reports whether the step started. Caller holds stateMu.
|
|
func (r *Reporter) enterReportedStep(step *runnerv1.StepState, timestamp time.Time) bool {
|
|
if prev := r.currentReportedStep; step != prev && prev.Stage == runnerv1.StepStage_STEP_STAGE_SETUP && prev.StoppedAt == nil {
|
|
if prev.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
|
prev.Result = runnerv1.Result_RESULT_SUCCESS
|
|
}
|
|
prev.StoppedAt = timestamppb.New(timestamp)
|
|
}
|
|
r.currentReportedStep = step
|
|
if step.StartedAt != nil {
|
|
return false
|
|
}
|
|
step.StartedAt = timestamppb.New(timestamp)
|
|
return true
|
|
}
|
|
|
|
// finishReportedSteps gives a result to the reported steps that have none when the job ends:
|
|
// the job's result to a step that was running, except the cleanup that ran in full. A running
|
|
// cleanup is left running unless closing, as it still logs after the job result. Caller holds stateMu.
|
|
func (r *Reporter) finishReportedSteps(jobResult runnerv1.Result, timestamp time.Time, closing bool) {
|
|
for _, v := range r.state.ReportedSteps {
|
|
if closing && v.StartedAt != nil && v.StoppedAt == nil {
|
|
v.StoppedAt = timestamppb.New(timestamp)
|
|
}
|
|
if v.Result != runnerv1.Result_RESULT_UNSPECIFIED || !closing && v.StartedAt != nil && v.Stage == runnerv1.StepStage_STEP_STAGE_CLEANUP {
|
|
continue
|
|
}
|
|
switch {
|
|
case v.StartedAt == nil && jobResult == runnerv1.Result_RESULT_CANCELLED:
|
|
v.Result = runnerv1.Result_RESULT_CANCELLED
|
|
case v.StartedAt == nil:
|
|
v.Result = runnerv1.Result_RESULT_SKIPPED // never reached, such as the steps after a failed setup
|
|
case v.Stage == runnerv1.StepStage_STEP_STAGE_CLEANUP:
|
|
v.Result = runnerv1.Result_RESULT_SUCCESS
|
|
default:
|
|
v.Result = jobResult
|
|
}
|
|
if v.StartedAt != nil {
|
|
v.StoppedAt = timestamppb.New(timestamp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enabled makes the Reporter see every record, the job logger's level filter runs before it.
|
|
func (r *Reporter) Enabled(context.Context, logging.Level) bool { return true }
|
|
|
|
// WithAttrs returns a Handler feeding the same Reporter, with attrs added to every record.
|
|
func (r *Reporter) WithAttrs(attrs []logging.Attr) logging.Handler {
|
|
return &reporterHandler{r: r, attrs: attrs}
|
|
}
|
|
|
|
type reporterHandler struct {
|
|
r *Reporter
|
|
attrs []logging.Attr
|
|
}
|
|
|
|
func (h *reporterHandler) Enabled(ctx context.Context, level logging.Level) bool {
|
|
return h.r.Enabled(ctx, level)
|
|
}
|
|
|
|
func (h *reporterHandler) Handle(ctx context.Context, rec logging.Record) error {
|
|
rec.Attrs = slices.Concat(h.attrs, rec.Attrs)
|
|
return h.r.Handle(ctx, rec)
|
|
}
|
|
|
|
func (h *reporterHandler) WithAttrs(attrs []logging.Attr) logging.Handler {
|
|
return &reporterHandler{r: h.r, attrs: slices.Concat(h.attrs, attrs)}
|
|
}
|
|
|
|
// appendLogRow masks a row before buffering it for Gitea and the local job.log, the one point
|
|
// feeding both. Caller holds stateMu.
|
|
func (r *Reporter) appendLogRow(row *runnerv1.LogRow) {
|
|
row.Content = r.mask(row.Content)
|
|
if step := r.currentReportedStep; step != nil {
|
|
if step.LogLength == 0 {
|
|
step.LogIndex = int64(r.logOffset + len(r.logRows))
|
|
}
|
|
step.LogLength++
|
|
}
|
|
r.logRows = append(r.logRows, row)
|
|
r.jobLog.write(row.Time.AsTime(), row.Content)
|
|
}
|
|
|
|
func attrArgs(attrs []logging.Attr) []any {
|
|
args := make([]any, len(attrs))
|
|
for i, a := range attrs {
|
|
args[i] = a
|
|
}
|
|
return args
|
|
}
|
|
|
|
// isJobStepEntry is false for composite action steps, whose results are not job step results.
|
|
func isJobStepEntry(rec logging.Record) bool {
|
|
stepID, _ := recordAttr[[]string](rec, "stepID")
|
|
return len(stepID) <= 1
|
|
}
|
|
|
|
// notifyLog sends a non-blocking signal that new log rows are available.
|
|
func (r *Reporter) notifyLog() {
|
|
select {
|
|
case r.logNotify <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// notifyState sends a non-blocking signal that a UX-critical state change occurred (step start/stop, job result).
|
|
func (r *Reporter) notifyState() {
|
|
select {
|
|
case r.stateNotify <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// unlockAndNotify releases stateMu and sends channel notifications.
|
|
// Must be called with stateMu held.
|
|
func (r *Reporter) unlockAndNotify(urgentState bool) {
|
|
r.stateMu.Unlock()
|
|
r.notifyLog()
|
|
if urgentState {
|
|
r.notifyState()
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
|
|
urgentState := false
|
|
|
|
r.stateMu.Lock()
|
|
|
|
r.stateChanged = true
|
|
|
|
if logging.Enabled(logging.LevelTrace) {
|
|
logging.With(attrArgs(entry.Attrs)...).Trace(r.mask(entry.Message)) // the process masker has no ::add-mask:: value
|
|
}
|
|
|
|
timestamp := entry.Time
|
|
if r.state.StartedAt == nil {
|
|
r.state.StartedAt = timestamppb.New(timestamp)
|
|
}
|
|
|
|
if reported, own := r.reportedStepOf(entry); reported != nil {
|
|
if r.enterReportedStep(reported, timestamp) {
|
|
urgentState = true
|
|
}
|
|
if !own && entry.Level >= logging.LevelError && reported.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
|
reported.Result = runnerv1.Result_RESULT_FAILURE // a job level error, such as an action that cannot be downloaded
|
|
urgentState = true
|
|
}
|
|
if v, ok := entry.Attr("stepResult"); ok && own && isJobStepEntry(entry) {
|
|
if stepResult, ok := parseResult(v); ok {
|
|
reported.Result = stepResult
|
|
reported.StoppedAt = timestamppb.New(timestamp)
|
|
urgentState = true
|
|
}
|
|
}
|
|
}
|
|
|
|
stage, _ := entry.Attr("stage")
|
|
|
|
if stage != runner.StageMain {
|
|
if v, ok := entry.Attr("jobResult"); ok {
|
|
if jobResult, ok := parseResult(v); ok {
|
|
// We need to ensure log upload before this upload
|
|
r.state.Result = jobResult
|
|
r.state.StoppedAt = timestamppb.New(timestamp)
|
|
for _, s := range r.state.Steps {
|
|
if s.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
|
s.Result = runnerv1.Result_RESULT_CANCELLED
|
|
if jobResult == runnerv1.Result_RESULT_SKIPPED {
|
|
s.Result = runnerv1.Result_RESULT_SKIPPED
|
|
}
|
|
}
|
|
}
|
|
r.finishReportedSteps(jobResult, timestamp, false)
|
|
urgentState = true
|
|
}
|
|
}
|
|
if r.shouldAppendLogRow(entry) {
|
|
r.appendLogRow(logRow(entry))
|
|
}
|
|
r.unlockAndNotify(urgentState)
|
|
return nil
|
|
}
|
|
|
|
var step *runnerv1.StepState
|
|
if v, ok := entry.Attr("stepNumber"); ok {
|
|
if v, ok := v.(int); ok && len(r.state.Steps) > v {
|
|
step = r.state.Steps[v]
|
|
}
|
|
}
|
|
if step == nil {
|
|
if r.shouldAppendLogRow(entry) {
|
|
r.appendLogRow(logRow(entry))
|
|
}
|
|
r.unlockAndNotify(false)
|
|
return nil
|
|
}
|
|
|
|
if step.StartedAt == nil {
|
|
step.StartedAt = timestamppb.New(timestamp)
|
|
urgentState = true
|
|
}
|
|
|
|
// Force reporting log errors as raw output to prevent silent failures
|
|
v, hasRawOutput := entry.Attr("raw_output")
|
|
rawOutput, _ := v.(bool)
|
|
if entry.Level >= logging.LevelError || rawOutput {
|
|
if step.LogLength == 0 {
|
|
step.LogIndex = int64(r.logOffset + len(r.logRows))
|
|
}
|
|
step.LogLength++
|
|
r.appendLogRow(logRow(entry))
|
|
} else if !hasRawOutput && r.shouldAppendLogRow(entry) {
|
|
r.appendLogRow(logRow(entry))
|
|
}
|
|
if v, ok := entry.Attr("stepResult"); ok && isJobStepEntry(entry) {
|
|
if stepResult, ok := parseResult(v); ok {
|
|
if step.LogLength == 0 {
|
|
step.LogIndex = int64(r.logOffset + len(r.logRows))
|
|
}
|
|
step.Result = stepResult
|
|
step.StoppedAt = timestamppb.New(timestamp)
|
|
urgentState = true
|
|
}
|
|
}
|
|
|
|
r.unlockAndNotify(urgentState)
|
|
return nil
|
|
}
|
|
|
|
// Only the daemon loop calls this, so reportFailing needs no lock.
|
|
func (r *Reporter) noteReport(method string, err error) {
|
|
if errors.Is(err, errOutputsNotSent) {
|
|
err = nil // the RPC itself succeeded
|
|
}
|
|
switch {
|
|
case err != nil && !r.reportFailing[method]:
|
|
r.reportFailing[method] = true
|
|
logging.Warnf("%s error: %v, retrying until reconnected", method, err)
|
|
case err == nil && r.reportFailing[method]:
|
|
delete(r.reportFailing, method)
|
|
logging.Infof("%s reconnected", method)
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) RunDaemon() {
|
|
go r.runDaemonLoop()
|
|
}
|
|
|
|
// StopHeartbeats stops periodic UpdateTask heartbeats without cancelling the
|
|
// task context. Close() still delivers the final flush. Safe to call multiple
|
|
// times and when the context is already cancelled.
|
|
func (r *Reporter) StopHeartbeats() {
|
|
r.heartbeatStopOnce.Do(func() {
|
|
close(r.heartbeatStop)
|
|
})
|
|
}
|
|
|
|
func (r *Reporter) runDaemonLoop() {
|
|
defer close(r.daemon)
|
|
|
|
logTicker := time.NewTicker(r.logReportInterval)
|
|
stateTicker := time.NewTicker(r.stateReportInterval)
|
|
|
|
// maxLatencyTimer ensures the first buffered log row is sent within logReportMaxLatency.
|
|
// Start inactive — it is armed when the first log row arrives in an empty buffer.
|
|
maxLatencyTimer := time.NewTimer(0)
|
|
maxLatencyTimer.Stop()
|
|
maxLatencyActive := false
|
|
stopLatencyTimer := func() {
|
|
maxLatencyTimer.Stop()
|
|
maxLatencyActive = false
|
|
}
|
|
|
|
defer logTicker.Stop()
|
|
defer stateTicker.Stop()
|
|
defer maxLatencyTimer.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-logTicker.C:
|
|
r.reportLogWithState()
|
|
stopLatencyTimer()
|
|
|
|
case <-stateTicker.C:
|
|
r.noteReport(metrics.LabelMethodUpdateTask, r.ReportState(false))
|
|
|
|
case <-r.logNotify:
|
|
r.stateMu.RLock()
|
|
n := len(r.logRows)
|
|
r.stateMu.RUnlock()
|
|
|
|
if n >= r.logBatchSize {
|
|
r.reportLogWithState()
|
|
stopLatencyTimer()
|
|
} else if !maxLatencyActive && n > 0 {
|
|
maxLatencyTimer.Reset(r.logReportMaxLatency)
|
|
maxLatencyActive = true
|
|
}
|
|
|
|
case <-r.stateNotify:
|
|
// Step transition or job result — flush both immediately for frontend UX.
|
|
r.noteReport(metrics.LabelMethodUpdateLog, r.ReportLog(false))
|
|
r.noteReport(metrics.LabelMethodUpdateTask, r.ReportState(false))
|
|
stopLatencyTimer()
|
|
|
|
case <-maxLatencyTimer.C:
|
|
maxLatencyActive = false
|
|
r.reportLogWithState()
|
|
|
|
case <-r.ctx.Done():
|
|
// Stop heartbeating on cancel so Gitea sees the runner as offline
|
|
// during cleanup and won't assign an overlapping task. Close() still
|
|
// delivers the final flush on a detached context (flushFinal).
|
|
return
|
|
|
|
case <-r.heartbeatStop:
|
|
// Stop heartbeating during post-task script execution. Close() still
|
|
// delivers the final flush on a detached context (flushFinal).
|
|
return
|
|
}
|
|
|
|
r.stateMu.RLock()
|
|
closed := r.closed
|
|
r.stateMu.RUnlock()
|
|
if closed {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Gitea slices the single log stream into steps by the LogIndex/LogLength the state
|
|
// carries, so rows acked without a state report behind them stay unattributed, and stay
|
|
// that way for good if the runner never reports again.
|
|
func (r *Reporter) reportLogWithState() {
|
|
took, err := r.reportLog(false)
|
|
r.noteReport(metrics.LabelMethodUpdateLog, err)
|
|
if took {
|
|
r.noteReport(metrics.LabelMethodUpdateTask, r.ReportState(false))
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) Logf(format string, a ...any) {
|
|
r.stateMu.Lock()
|
|
defer r.stateMu.Unlock()
|
|
|
|
r.logf(format, a...)
|
|
}
|
|
|
|
func (r *Reporter) logf(format string, a ...any) {
|
|
if !r.duringSteps() {
|
|
// Masked like any other row, as a caller can interpolate a secret such as a configured URL
|
|
// carrying credentials.
|
|
r.appendLogRow(&runnerv1.LogRow{Time: timestamppb.Now(), Content: fmt.Sprintf(format, a...)})
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) SetOutputs(outputs map[string]string) {
|
|
r.stateMu.Lock()
|
|
defer r.stateMu.Unlock()
|
|
r.outputsMu.Lock()
|
|
defer r.outputsMu.Unlock()
|
|
|
|
if r.outputs == nil {
|
|
r.outputs = map[string]jobOutput{}
|
|
}
|
|
for k, v := range outputs {
|
|
if l := len(k); l > maxOutputKeyLen {
|
|
logging.Warnf("ignore output %q because the key is too long: %d > %d", k, l, maxOutputKeyLen)
|
|
r.logf("ignore output %q because the key is too long: %d > %d", k, l, maxOutputKeyLen)
|
|
continue
|
|
}
|
|
if l := len(v); l > maxOutputValueLen {
|
|
logging.Warnf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen)
|
|
r.logf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen)
|
|
continue
|
|
}
|
|
if r.logReplacer.Replace(v) != v { // GitHub skips an output that may carry a secret rather than masking it
|
|
logging.Warnf("ignore output %q because it may contain a secret", k)
|
|
r.logf("ignore output %q because it may contain a secret", k)
|
|
continue
|
|
}
|
|
if _, ok := r.outputs[k]; !ok {
|
|
r.outputs[k] = jobOutput{value: v}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) Close(lastWords string) error {
|
|
defer deregisterGlobalMasks(r) // deferred so a panic below cannot strand this task's masks
|
|
r.stateMu.Lock()
|
|
r.closed = true
|
|
if r.state.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
|
// No result of its own, so say why it stopped.
|
|
result, words := runnerv1.Result_RESULT_FAILURE, "Early termination"
|
|
switch {
|
|
case r.serverResult != runnerv1.Result_RESULT_UNSPECIFIED:
|
|
result, words = r.serverResult, "Ended by the server"
|
|
case errors.Is(r.ctx.Err(), context.Canceled):
|
|
result, words = runnerv1.Result_RESULT_CANCELLED, "Cancelled"
|
|
}
|
|
if lastWords == "" {
|
|
lastWords = words
|
|
}
|
|
for _, v := range r.state.Steps {
|
|
if v.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
|
v.Result = runnerv1.Result_RESULT_CANCELLED
|
|
}
|
|
}
|
|
r.state.Result = result
|
|
r.appendLogRow(&runnerv1.LogRow{
|
|
Time: timestamppb.Now(),
|
|
Content: lastWords,
|
|
})
|
|
r.state.StoppedAt = timestamppb.Now()
|
|
} else if lastWords != "" {
|
|
r.appendLogRow(&runnerv1.LogRow{
|
|
Time: timestamppb.Now(),
|
|
Content: lastWords,
|
|
})
|
|
}
|
|
r.finishReportedSteps(r.state.Result, time.Now(), true)
|
|
r.stateMu.Unlock()
|
|
|
|
// Wake up the daemon loop so it detects closed promptly.
|
|
r.notifyLog()
|
|
|
|
// Wait for Acknowledge
|
|
select {
|
|
case <-r.daemon:
|
|
case <-time.After(r.daemonWait):
|
|
logging.Errorf("No Response from RunDaemon for %s, continue best effort", r.daemonWait)
|
|
}
|
|
|
|
// Gitea's UpdateLog short-circuits on len(Rows)==0 before honoring NoMore,
|
|
// so a final empty request never runs TransferLogs and dbfs_data leaks.
|
|
// Inject a sentinel row after the daemon has exited so it can't be flushed
|
|
// before ReportLog(true).
|
|
// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all
|
|
// supported branches, e.g. v1.28+.
|
|
r.stateMu.Lock()
|
|
if len(r.logRows) == 0 {
|
|
// Not appendLogRow: the sentinel is not job output and has no place in job.log.
|
|
r.logRows = append(r.logRows, &runnerv1.LogRow{
|
|
Time: timestamppb.Now(),
|
|
Content: "",
|
|
})
|
|
}
|
|
r.stateMu.Unlock()
|
|
|
|
// Separate budgets so a slow ReportLog can't starve the ReportState that
|
|
// carries the cancel acknowledgement.
|
|
err := errors.Join(
|
|
r.flushFinal(func() error { return r.ReportLog(true) }),
|
|
r.flushFinal(func() error { return r.ReportState(true) }),
|
|
)
|
|
|
|
// After the flush so a failed handover is in the file too, under stateMu so a late entry cannot race.
|
|
r.stateMu.Lock()
|
|
trailer := fmt.Sprintf("task %d finished: %s", r.state.Id, metrics.ResultToStatusLabel(r.state.Result))
|
|
if err != nil {
|
|
trailer += fmt.Sprintf(", the final flush to Gitea failed: %v", err)
|
|
err = errors.New(r.mask(err.Error())) // the caller logs it after this task's masks leave the global masker
|
|
}
|
|
r.jobLog.close(r.mask(trailer))
|
|
r.stateMu.Unlock()
|
|
|
|
return err
|
|
}
|
|
|
|
// flushFinal retries fn on a detached, bounded context so a cancelled r.ctx
|
|
// does not abort the final flush. Each call gets its own fresh budget.
|
|
func (r *Reporter) flushFinal(fn func() error) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*r.effectiveCloseTimeout())
|
|
defer cancel()
|
|
var errs []error
|
|
for attempt := range 10 {
|
|
err := fn()
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
errs = append(errs, err)
|
|
if attempt == 9 {
|
|
break
|
|
}
|
|
select {
|
|
case <-time.After(100*time.Millisecond<<attempt + rand.N(100*time.Millisecond)):
|
|
case <-ctx.Done():
|
|
return errors.Join(append(errs, ctx.Err())...)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// effectiveCloseTimeout returns closeTimeout, or 10s when unset, so a zero
|
|
// value can't produce an already-expired context for the final flush.
|
|
func (r *Reporter) effectiveCloseTimeout() time.Duration {
|
|
if r.closeTimeout <= 0 {
|
|
return 10 * time.Second
|
|
}
|
|
return r.closeTimeout
|
|
}
|
|
|
|
// rpcCtx returns the context for an outbound RPC plus a cancel func. While
|
|
// r.ctx is alive it's used directly; once cancelled (server RESULT_CANCELLED),
|
|
// RPCs switch to a fresh bounded context so Close()'s final flush still lands.
|
|
func (r *Reporter) rpcCtx() (context.Context, context.CancelFunc) {
|
|
select {
|
|
case <-r.ctx.Done():
|
|
return context.WithTimeout(context.Background(), r.effectiveCloseTimeout())
|
|
default:
|
|
return r.ctx, func() {}
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) ReportLog(noMore bool) error {
|
|
_, err := r.reportLog(noMore)
|
|
return err
|
|
}
|
|
|
|
// reportLog also reports whether the server took rows it had not taken before.
|
|
func (r *Reporter) reportLog(noMore bool) (bool, error) {
|
|
r.clientM.Lock()
|
|
defer r.clientM.Unlock()
|
|
|
|
r.stateMu.RLock()
|
|
rows := r.logRows
|
|
r.stateMu.RUnlock()
|
|
|
|
if !noMore && len(rows) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
rpcCtx, rpcCancel := r.rpcCtx()
|
|
defer rpcCancel()
|
|
|
|
start := time.Now()
|
|
resp, err := r.client.UpdateLog(rpcCtx, &runnerv1.UpdateLogRequest{
|
|
TaskId: r.state.Id,
|
|
Index: int64(r.logOffset),
|
|
Rows: rows,
|
|
NoMore: noMore,
|
|
})
|
|
metrics.ReportLogDuration.Observe(time.Since(start).Seconds())
|
|
if err != nil {
|
|
metrics.ReportLogTotal.WithLabelValues(metrics.LabelResultError).Inc()
|
|
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodUpdateLog, connect.CodeOf(err).String()).Inc()
|
|
return false, err
|
|
}
|
|
metrics.ReportLogTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
|
|
|
|
ack := int(resp.AckIndex)
|
|
if ack < r.logOffset {
|
|
return false, errors.New("submitted logs are lost")
|
|
}
|
|
|
|
r.stateMu.Lock()
|
|
submitted := r.logOffset + len(rows)
|
|
// A server can ack beyond what it was sent; clamp to stay within the buffer.
|
|
ack = min(ack, submitted)
|
|
took := ack > r.logOffset
|
|
r.logRows = r.logRows[ack-r.logOffset:]
|
|
r.logOffset = ack
|
|
remaining := len(r.logRows)
|
|
r.stateMu.Unlock()
|
|
if remaining != r.lastLogBufferRows {
|
|
metrics.ReportLogBufferRows.Set(float64(remaining))
|
|
r.lastLogBufferRows = remaining
|
|
}
|
|
|
|
if noMore && ack < submitted {
|
|
return took, errors.New("not all logs are submitted")
|
|
}
|
|
|
|
return took, nil
|
|
}
|
|
|
|
// ReportState only reports the job result if reportResult is true
|
|
// RunDaemon never reports results even if result is set
|
|
func (r *Reporter) ReportState(reportResult bool) error {
|
|
r.clientM.Lock()
|
|
defer r.clientM.Unlock()
|
|
|
|
outputs := make(map[string]string)
|
|
r.outputsMu.Lock()
|
|
for key, out := range r.outputs {
|
|
if !out.sent {
|
|
outputs[key] = out.value
|
|
}
|
|
}
|
|
r.outputsMu.Unlock()
|
|
|
|
// Consume stateChanged atomically with the snapshot; restored on error
|
|
// below so a log record handled during UpdateTask isn't silently lost.
|
|
// Heartbeat at stateReportInterval even when nothing changed, so the server
|
|
// doesn't time out long-running silent jobs as orphaned (#826).
|
|
last := r.lastReportedAtNanos
|
|
withinHeartbeatInterval := last != 0 && time.Since(time.Unix(0, last)) < r.stateReportInterval
|
|
r.stateMu.Lock()
|
|
if !reportResult && !r.stateChanged && len(outputs) == 0 && withinHeartbeatInterval {
|
|
r.stateMu.Unlock()
|
|
return nil
|
|
}
|
|
state := proto.CloneOf(r.state)
|
|
r.stateChanged = false
|
|
r.stateMu.Unlock()
|
|
|
|
if !reportResult {
|
|
state.Result = runnerv1.Result_RESULT_UNSPECIFIED
|
|
}
|
|
|
|
rpcCtx, rpcCancel := r.rpcCtx()
|
|
defer rpcCancel()
|
|
|
|
start := time.Now()
|
|
resp, err := r.client.UpdateTask(rpcCtx, &runnerv1.UpdateTaskRequest{
|
|
State: state,
|
|
Outputs: outputs,
|
|
})
|
|
metrics.ReportStateDuration.Observe(time.Since(start).Seconds())
|
|
if err != nil {
|
|
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultError).Inc()
|
|
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodUpdateTask, connect.CodeOf(err).String()).Inc()
|
|
r.stateMu.Lock()
|
|
r.stateChanged = true
|
|
r.stateMu.Unlock()
|
|
return err
|
|
}
|
|
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
|
|
r.lastReportedAtNanos = time.Now().UnixNano()
|
|
|
|
var noSent []string
|
|
r.outputsMu.Lock()
|
|
for _, k := range resp.SentOutputs {
|
|
if _, ok := r.outputs[k]; ok {
|
|
r.outputs[k] = jobOutput{sent: true}
|
|
}
|
|
}
|
|
for key, out := range r.outputs {
|
|
if !out.sent {
|
|
noSent = append(noSent, key)
|
|
}
|
|
}
|
|
r.outputsMu.Unlock()
|
|
|
|
// A terminal result means the server is done with this task; keep running and
|
|
// the job holds a capacity slot until runner.timeout.
|
|
if state := resp.State; state != nil && state.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
|
r.stateMu.Lock()
|
|
r.serverResult = state.Result
|
|
r.stateMu.Unlock()
|
|
r.cancel()
|
|
}
|
|
if len(noSent) > 0 {
|
|
return fmt.Errorf("%w: %v", errOutputsNotSent, noSent)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Reporter) duringSteps() bool {
|
|
if steps := r.state.Steps; len(steps) == 0 {
|
|
return false
|
|
} else if first := steps[0]; first.Result == runnerv1.Result_RESULT_UNSPECIFIED && first.LogLength == 0 {
|
|
return false
|
|
} else if last := steps[len(steps)-1]; last.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// shouldAppendLogRow reports whether a non-raw_output entry should be written
|
|
// to the job log: only when we are between steps and the entry's level is
|
|
// within runner.log_report_level.
|
|
func (r *Reporter) shouldAppendLogRow(entry logging.Record) bool {
|
|
return !r.duringSteps() && entry.Level >= r.logReportLevel
|
|
}
|
|
|
|
var stringToResult = map[string]runnerv1.Result{
|
|
"success": runnerv1.Result_RESULT_SUCCESS,
|
|
"failure": runnerv1.Result_RESULT_FAILURE,
|
|
"skipped": runnerv1.Result_RESULT_SKIPPED,
|
|
"cancelled": runnerv1.Result_RESULT_CANCELLED,
|
|
}
|
|
|
|
func parseResult(result any) (runnerv1.Result, bool) {
|
|
str := ""
|
|
if v, ok := result.(string); ok { // for jobResult
|
|
str = v
|
|
} else if v, ok := result.(fmt.Stringer); ok { // for stepResult
|
|
str = v.String()
|
|
}
|
|
|
|
ret, ok := stringToResult[str]
|
|
return ret, ok
|
|
}
|
|
|
|
func logRow(entry logging.Record) *runnerv1.LogRow {
|
|
return &runnerv1.LogRow{Time: timestamppb.New(entry.Time), Content: strings.TrimRight(entry.Message, "\r\n")}
|
|
}
|
|
|
|
// mask repairs the content first, so a secret the repair itself spells out is still caught.
|
|
func (r *Reporter) mask(content string) string {
|
|
return r.logReplacer.Replace(strings.ToValidUTF8(content, "?"))
|
|
}
|
|
|
|
// AddMask deliberately leaves the process-wide masker alone. Its entries come from every live
|
|
// task at once, so a workflow could otherwise mask "error" there and rewrite the runner's own
|
|
// log, and every other task's, for the rest of the job.
|
|
func (r *Reporter) AddMask(msg string) {
|
|
r.stateMu.Lock()
|
|
defer r.stateMu.Unlock()
|
|
r.oldnew = runner.AppendSecretMasker(r.oldnew, msg)
|
|
r.logReplacer = runner.NewSecretReplacer(r.oldnew)
|
|
}
|