mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-22 09:44:24 +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:
@@ -44,11 +44,13 @@ type Reporter struct {
|
||||
// so the gauge skips no-op Set calls when the buffer size is unchanged.
|
||||
lastLogBufferRows int
|
||||
|
||||
state *runnerv1.TaskState
|
||||
stateChanged bool
|
||||
stateMu sync.RWMutex
|
||||
outputs sync.Map
|
||||
daemon chan struct{}
|
||||
state *runnerv1.TaskState
|
||||
stateChanged bool
|
||||
stateMu sync.RWMutex
|
||||
outputs sync.Map
|
||||
daemon chan struct{}
|
||||
heartbeatStop chan struct{}
|
||||
heartbeatStopOnce sync.Once
|
||||
|
||||
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
|
||||
// guard in ReportState reads it without contending stateMu.
|
||||
@@ -99,7 +101,8 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
|
||||
state: &runnerv1.TaskState{
|
||||
Id: task.Id,
|
||||
},
|
||||
daemon: make(chan struct{}),
|
||||
daemon: make(chan struct{}),
|
||||
heartbeatStop: make(chan struct{}),
|
||||
}
|
||||
|
||||
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
|
||||
@@ -273,6 +276,15 @@ 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) stopLatencyTimer(active *bool, timer *time.Timer) {
|
||||
if *active {
|
||||
if !timer.Stop() {
|
||||
@@ -339,6 +351,12 @@ func (r *Reporter) runDaemonLoop() {
|
||||
// delivers the final flush on a detached context (flushFinal).
|
||||
close(r.daemon)
|
||||
return
|
||||
|
||||
case <-r.heartbeatStop:
|
||||
// Stop heartbeating during post-task script execution. Close() still
|
||||
// delivers the final flush on a detached context (flushFinal).
|
||||
close(r.daemon)
|
||||
return
|
||||
}
|
||||
|
||||
r.stateMu.RLock()
|
||||
|
||||
@@ -921,3 +921,65 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
|
||||
assert.True(t, foundCancelled, "final log must contain a 'Cancelled' row")
|
||||
assert.False(t, foundEarlyTermination, "final log must not contain 'Early termination' on the cancel path")
|
||||
}
|
||||
|
||||
// TestReporter_StopHeartbeats verifies that StopHeartbeats ends periodic
|
||||
// UpdateTask heartbeats while Close() still flushes the final state.
|
||||
func TestReporter_StopHeartbeats(t *testing.T) {
|
||||
var updateTaskCalls atomic.Int64
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Maybe().Return(
|
||||
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
|
||||
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
|
||||
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
updateTaskCalls.Add(1)
|
||||
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
cfg.Runner.StateReportInterval = 20 * time.Millisecond
|
||||
cfg.Runner.LogReportInterval = time.Hour
|
||||
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(1)
|
||||
reporter.RunDaemon()
|
||||
|
||||
reporter.stateMu.Lock()
|
||||
reporter.stateChanged = true
|
||||
reporter.state.Result = runnerv1.Result_RESULT_SUCCESS
|
||||
reporter.state.StoppedAt = timestamppb.Now()
|
||||
reporter.stateMu.Unlock()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return updateTaskCalls.Load() >= 1
|
||||
}, time.Second, 5*time.Millisecond, "daemon must send at least one UpdateTask before StopHeartbeats")
|
||||
|
||||
beforeStop := updateTaskCalls.Load()
|
||||
reporter.StopHeartbeats()
|
||||
|
||||
select {
|
||||
case <-reporter.daemon:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("StopHeartbeats must stop the daemon loop")
|
||||
}
|
||||
|
||||
time.Sleep(3 * cfg.Runner.StateReportInterval)
|
||||
assert.Equal(t, beforeStop, updateTaskCalls.Load(),
|
||||
"UpdateTask must not be called after StopHeartbeats")
|
||||
|
||||
require.NoError(t, reporter.Close(""))
|
||||
assert.Greater(t, updateTaskCalls.Load(), beforeStop,
|
||||
"Close() must still send a final UpdateTask after StopHeartbeats")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user