mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-22 01:34:25 +02:00
feat: Add optional runner.post_task_script hook after task cleanup (#1026)
- Adds `runner.post_task_script` and `runner.post_task_script_timeout` (default `5m`) to run a host executable after each task’s built-in cleanup (post-steps, container teardown, bind-workdir removal). - Stops task heartbeats via `Reporter.StopHeartbeats()` while the script runs so Gitea won’t assign overlapping work; the final task acknowledgement still happens in `reporter.Close()`. - Script output goes to the runner process log; non-zero exits are warned only and do not change the job result. - Documents lifecycle, offline behavior, timeouts, and Windows limits (`.ps1` not supported yet) in `docs/post-task-script.md`. Reviewed-on: https://gitea.com/gitea/runner/pulls/1026 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
This commit is contained in:
132
internal/app/run/post_task_script.go
Normal file
132
internal/app/run/post_task_script.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/runner/internal/pkg/process"
|
||||
"gitea.com/gitea/runner/internal/pkg/report"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (r *Runner) runPostTaskScript(ctx context.Context, reporter *report.Reporter, task *runnerv1.Task, workdir string) {
|
||||
script := r.cfg.Runner.PostTaskScript
|
||||
if script == "" {
|
||||
return
|
||||
}
|
||||
|
||||
timeout := r.cfg.Runner.PostTaskScriptTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = config.DefaultPostTaskScriptTimeout
|
||||
}
|
||||
|
||||
scriptCtx, cancel := postTaskScriptContext(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
env := r.postTaskScriptEnv(reporter, task, workdir)
|
||||
log.Infof("running post-task script %q for task %d", script, task.Id)
|
||||
|
||||
cmd := exec.CommandContext(scriptCtx, script)
|
||||
cmd.Env = envListFromMap(env)
|
||||
cmd.SysProcAttr = process.SysProcAttr(script, false)
|
||||
|
||||
stdout := postTaskScriptLogWriter("stdout")
|
||||
stderr := postTaskScriptLogWriter("stderr")
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
// Kill the script's whole process tree on cancellation and bound the post-exit
|
||||
// I/O wait, so a backgrounded child inheriting cmd's stdout/stderr pipe can
|
||||
// never hang cmd.Wait() and the runner. See process.TreeKill.
|
||||
treeKill := process.NewTreeKill(cmd)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
|
||||
return
|
||||
}
|
||||
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||
log.Warnf("post-task script %q for task %d: process tree kill setup failed, falling back to single-process kill: %v", script, task.Id, kerr)
|
||||
} else {
|
||||
defer k.Close()
|
||||
}
|
||||
|
||||
err := cmd.Wait()
|
||||
// Flush any trailing, not-yet-newline-terminated output now that the I/O
|
||||
// copiers have finished (cmd.Wait, bounded by WaitDelay above, guarantees it).
|
||||
common.FlushWriter(stdout)
|
||||
common.FlushWriter(stderr)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
|
||||
return
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
log.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode())
|
||||
return
|
||||
}
|
||||
log.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func postTaskScriptContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
// Detach from the task context's deadline and cancellation: the task has
|
||||
// already finished by the time the post-task script runs, so the script must
|
||||
// get its full configured timeout. Inheriting the task deadline would silently
|
||||
// truncate that budget when the job completed close to its own timeout (and an
|
||||
// already-cancelled task context would skip the script entirely).
|
||||
// context.WithoutCancel keeps the context values while dropping the deadline.
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
}
|
||||
|
||||
func (r *Runner) postTaskScriptEnv(reporter *report.Reporter, task *runnerv1.Task, workdir string) map[string]string {
|
||||
env := r.cloneEnvs()
|
||||
env["GITEA_TASK_ID"] = strconv.FormatInt(task.Id, 10)
|
||||
env["GITEA_WORKSPACE"] = workdir
|
||||
// GITEA_JOB_RESULT shares the runner's canonical result vocabulary
|
||||
// (success/failure/cancelled/skipped/unknown), the same strings the reporter
|
||||
// parses and the metrics labels use.
|
||||
env["GITEA_JOB_RESULT"] = metrics.ResultToStatusLabel(reporter.Result())
|
||||
if v := task.Context.Fields["run_id"].GetStringValue(); v != "" {
|
||||
env["GITEA_RUN_ID"] = v
|
||||
}
|
||||
if v := task.Context.Fields["repository"].GetStringValue(); v != "" {
|
||||
env["GITEA_REPOSITORY"] = v
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func envListFromMap(env map[string]string) []string {
|
||||
envList := make([]string, 0, len(env))
|
||||
for k, v := range env {
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return envList
|
||||
}
|
||||
|
||||
// postTaskScriptLogWriter returns an io.Writer that logs the script's output one
|
||||
// line at a time, tagged with the stream name. It is passed as cmd.Stdout/Stderr
|
||||
// (rather than a StdoutPipe) so that cmd.WaitDelay governs the copying goroutine:
|
||||
// a backgrounded process holding the pipe open can never block cmd.Wait()
|
||||
// indefinitely. Flush any trailing partial line with common.FlushWriter after
|
||||
// cmd.Wait() returns.
|
||||
func postTaskScriptLogWriter(stream string) io.Writer {
|
||||
return common.NewLineWriter(func(line string) bool {
|
||||
log.Infof("post-task script %s: %s", stream, strings.TrimRight(line, "\r\n"))
|
||||
return true
|
||||
})
|
||||
}
|
||||
157
internal/app/run/post_task_script_test.go
Normal file
157
internal/app/run/post_task_script_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/runner/internal/pkg/report"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestRunPostTaskScriptSkippedWhenEmpty(t *testing.T) {
|
||||
r := &Runner{
|
||||
cfg: &config.Config{},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
task := &runnerv1.Task{Id: 1, Context: taskCtx}
|
||||
reporter := report.NewReporter(ctx, cancel, nil, task, r.cfg)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunPostTaskScriptNonZeroExitDoesNotPanic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
scriptPath := filepath.Join(dir, "fail.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\nexit 2\n"), 0o700))
|
||||
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
cfg.Runner.PostTaskScript = scriptPath
|
||||
|
||||
r := &Runner{cfg: cfg}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
task := &runnerv1.Task{Id: 1, Context: taskCtx}
|
||||
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostTaskScriptContextUsesFullTimeout(t *testing.T) {
|
||||
const timeout = 5 * time.Minute
|
||||
|
||||
// A task context that finished close to its own deadline must not truncate the
|
||||
// script's budget: the script should still get its full configured timeout.
|
||||
near, cancelNear := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancelNear()
|
||||
scriptCtx, cancel := postTaskScriptContext(near, timeout)
|
||||
defer cancel()
|
||||
deadline, ok := scriptCtx.Deadline()
|
||||
require.True(t, ok)
|
||||
assert.Greater(t, time.Until(deadline), time.Minute, "script timeout truncated to task deadline")
|
||||
|
||||
// An already-cancelled task context must not cancel the script either.
|
||||
cancelledCtx, cancelIt := context.WithCancel(context.Background())
|
||||
cancelIt()
|
||||
scriptCtx2, cancel2 := postTaskScriptContext(cancelledCtx, timeout)
|
||||
defer cancel2()
|
||||
assert.NoError(t, scriptCtx2.Err(), "script context inherited the cancelled task context")
|
||||
}
|
||||
|
||||
func TestPostTaskScriptEnv(t *testing.T) {
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := &Runner{
|
||||
cfg: cfg,
|
||||
envs: map[string]string{"BASE": "1"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{
|
||||
"run_id": "99",
|
||||
"repository": "acme/widget",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
task := &runnerv1.Task{Id: 3, Context: taskCtx}
|
||||
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
|
||||
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_FAILURE)
|
||||
|
||||
env := r.postTaskScriptEnv(reporter, task, "/tmp/workspace")
|
||||
assert.Equal(t, "1", env["BASE"])
|
||||
assert.Equal(t, "3", env["GITEA_TASK_ID"])
|
||||
assert.Equal(t, "99", env["GITEA_RUN_ID"])
|
||||
assert.Equal(t, "acme/widget", env["GITEA_REPOSITORY"])
|
||||
assert.Equal(t, "/tmp/workspace", env["GITEA_WORKSPACE"])
|
||||
assert.Equal(t, "failure", env["GITEA_JOB_RESULT"])
|
||||
}
|
||||
|
||||
func TestRunPostTaskScriptIntegration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outFile := filepath.Join(dir, "out.txt")
|
||||
scriptPath := filepath.Join(dir, "post-task.sh")
|
||||
script := "#!/bin/sh\nprintf '%s %s %s' \"$GITEA_TASK_ID\" \"$GITEA_JOB_RESULT\" \"$CUSTOM\" > \"" + outFile + "\"\n"
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o700))
|
||||
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
cfg.Runner.PostTaskScript = scriptPath
|
||||
|
||||
r := &Runner{
|
||||
cfg: cfg,
|
||||
envs: map[string]string{"CUSTOM": "runner-env"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
task := &runnerv1.Task{Id: 11, Context: taskCtx}
|
||||
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
|
||||
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS)
|
||||
|
||||
r.runPostTaskScript(ctx, reporter, task, "/workspace/acme/repo")
|
||||
|
||||
content, err := os.ReadFile(outFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "11 success runner-env", string(content))
|
||||
}
|
||||
|
||||
func setReporterJobResult(t *testing.T, reporter *report.Reporter, result runnerv1.Result) {
|
||||
t.Helper()
|
||||
require.NoError(t, reporter.Fire(&log.Entry{
|
||||
Time: time.Now(),
|
||||
Message: "job finished",
|
||||
Data: log.Fields{
|
||||
"stage": "Post",
|
||||
"jobResult": metrics.ResultToStatusLabel(result),
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -475,6 +475,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
}
|
||||
}
|
||||
|
||||
reporter.StopHeartbeats()
|
||||
r.runPostTaskScript(ctx, reporter, task, workdir)
|
||||
|
||||
return execErr
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user