mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-21 17:24:23 +02:00
Compare commits
16 Commits
0a2f28244d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
007717956a | ||
|
|
df0370f8bf | ||
|
|
5f0636faad | ||
|
|
4997f33b5f | ||
|
|
2963716953 | ||
|
|
3996d6d032 | ||
|
|
205af7cd01 | ||
|
|
33e6d1d8ff | ||
|
|
56979e6ab8 | ||
|
|
bf99e6a758 | ||
|
|
740a3d4db4 | ||
|
|
822af5029f | ||
|
|
526c46b485 | ||
|
|
355289bc54 | ||
|
|
e583b0706b | ||
|
|
8ad84cd96a |
@@ -17,7 +17,7 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.5.2-dind AS dind
|
||||
FROM docker:29.5.3-dind AS dind
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### DIND-ROOTLESS VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.5.2-dind-rootless AS dind-rootless
|
||||
FROM docker:29.5.3-dind-rootless AS dind-rootless
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -63,7 +63,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### BASIC VARIANT
|
||||
#
|
||||
#
|
||||
FROM alpine:3.23 AS basic
|
||||
FROM alpine:3.24 AS basic
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
||||
85
README.md
85
README.md
@@ -85,6 +85,44 @@ docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRA
|
||||
|
||||
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
||||
|
||||
### Image flavours
|
||||
|
||||
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
||||
|
||||
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
||||
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
||||
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
||||
|
||||
#### `latest` — basic
|
||||
|
||||
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
||||
|
||||
```bash
|
||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
||||
```
|
||||
|
||||
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
||||
|
||||
#### `latest-dind` — Docker-in-Docker
|
||||
|
||||
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
||||
|
||||
```bash
|
||||
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
--name my_runner gitea/runner:latest-dind
|
||||
```
|
||||
|
||||
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
||||
|
||||
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
||||
|
||||
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
||||
|
||||
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
||||
|
||||
### Configuration
|
||||
|
||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
||||
@@ -122,9 +160,42 @@ Prefer a YAML file for all settings.
|
||||
|
||||
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
|
||||
|
||||
#### External cache (`actions/cache`)
|
||||
#### Caching (`actions/cache`)
|
||||
|
||||
If `cache.external_server` is set, you must set `cache.external_secret` to the same value on this runner and on the standalone cache server. Run the server with `gitea-runner cache-server` using a config that defines `cache.external_secret` (and matching `cache.dir` / host / port as needed). Flags `--dir`, `--host`, and `--port` on `cache-server` override the file.
|
||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||
|
||||
**Shared cache across multiple runners**
|
||||
|
||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
|
||||
1. Create a config file for the cache server host:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
dir: /data/actcache
|
||||
port: 8088
|
||||
external_secret: "replace-with-a-strong-random-secret"
|
||||
```
|
||||
|
||||
2. Start the server:
|
||||
|
||||
```bash
|
||||
gitea-runner -c cache-server-config.yaml cache-server
|
||||
```
|
||||
|
||||
3. On every runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
external_server: "http://<cache-server-host>:8088/"
|
||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
||||
```
|
||||
|
||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||
|
||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
||||
|
||||
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
||||
|
||||
#### Official Docker image
|
||||
|
||||
@@ -138,6 +209,16 @@ When `container.bind_workdir` is enabled, stale task workspace directories can b
|
||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||
|
||||
#### Post-task script (`runner.post_task_script`)
|
||||
|
||||
Optional host script that runs **after** each task's built-in cleanup (post-steps, container teardown, bind-workdir removal). Use it for extra machine housekeeping — Docker pruning, disk cleanup, and similar.
|
||||
|
||||
**While the script runs, the runner stops task heartbeats and stays offline from Gitea's perspective until the script exits (or hits `runner.post_task_script_timeout`, default `5m`).** A script that blocks without exiting keeps the runner from taking new work for up to that timeout. Script output goes to the runner log, not the job log; a non-zero exit is warned but does not change the job result.
|
||||
|
||||
On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not supported yet** as the configured path — wrap commands in a `.cmd` file instead.
|
||||
|
||||
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||
|
||||
### Example Deployments
|
||||
|
||||
Check out the [examples](examples) directory for sample deployment types.
|
||||
|
||||
@@ -12,6 +12,13 @@ import (
|
||||
// LineHandler is a callback function for handling a line
|
||||
type LineHandler func(line string) bool
|
||||
|
||||
// Flusher is implemented by writers that buffer a trailing, not-yet-terminated
|
||||
// line. Callers should flush once the underlying stream has reached EOF so the
|
||||
// final line (when it is not newline-terminated) is not lost.
|
||||
type Flusher interface {
|
||||
Flush()
|
||||
}
|
||||
|
||||
type lineWriter struct {
|
||||
buffer bytes.Buffer
|
||||
handlers []LineHandler
|
||||
@@ -24,6 +31,14 @@ func NewLineWriter(handlers ...LineHandler) io.Writer {
|
||||
return w
|
||||
}
|
||||
|
||||
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
|
||||
// callers can flush an io.Writer without knowing its concrete type.
|
||||
func FlushWriter(w io.Writer) {
|
||||
if f, ok := w.(Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||
pBuf := bytes.NewBuffer(p)
|
||||
written := 0
|
||||
@@ -44,6 +59,17 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// Flush emits any buffered, not-yet-newline-terminated content as a final line.
|
||||
// It is safe to call multiple times; subsequent calls with an empty buffer are
|
||||
// no-ops.
|
||||
func (lw *lineWriter) Flush() {
|
||||
if lw.buffer.Len() == 0 {
|
||||
return
|
||||
}
|
||||
lw.handleLine(lw.buffer.String())
|
||||
lw.buffer.Reset()
|
||||
}
|
||||
|
||||
func (lw *lineWriter) handleLine(line string) {
|
||||
for _, h := range lw.handlers {
|
||||
ok := h(line)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -39,3 +40,33 @@ func TestLineWriter(t *testing.T) {
|
||||
assert.Equal(" and another\n", lines[2])
|
||||
assert.Equal("last line\n", lines[3])
|
||||
}
|
||||
|
||||
func TestLineWriterFlush(t *testing.T) {
|
||||
lines := make([]string, 0)
|
||||
lineHandler := func(s string) bool {
|
||||
lines = append(lines, s)
|
||||
return true
|
||||
}
|
||||
|
||||
lineWriter := NewLineWriter(lineHandler)
|
||||
|
||||
assert := assert.New(t)
|
||||
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
|
||||
|
||||
// Only the newline-terminated line is emitted before flushing.
|
||||
assert.Equal([]string{"complete line\n"}, lines)
|
||||
|
||||
// Flushing emits the buffered, not-yet-terminated trailing line.
|
||||
FlushWriter(lineWriter)
|
||||
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines)
|
||||
|
||||
// Flushing again is a no-op: nothing is buffered.
|
||||
FlushWriter(lineWriter)
|
||||
assert.Len(lines, 2)
|
||||
}
|
||||
|
||||
func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
|
||||
// FlushWriter must be a safe no-op for writers that do not buffer lines.
|
||||
assert.NotPanics(t, func() { FlushWriter(io.Discard) })
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ type NewDockerBuildExecutorInput struct {
|
||||
Platform string
|
||||
}
|
||||
|
||||
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||
type NewDockerNetworkCreateExecutorInput struct {
|
||||
EnableIPv4 *bool
|
||||
EnableIPv6 *bool
|
||||
}
|
||||
|
||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||
type NewDockerPullExecutorInput struct {
|
||||
Image string
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
@@ -39,6 +39,8 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
@@ -45,6 +46,13 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||
// finish draining a container's output before returning, so that neither a
|
||||
// cancellation (waitForCommand) nor a normal container exit (wait) truncates
|
||||
// the tail of the log. It is a safety bound: in the common case the stream
|
||||
// reaches EOF and the goroutine returns well before this elapses.
|
||||
const drainGracePeriod = 2 * time.Second
|
||||
|
||||
// NewContainer creates a reference to a container
|
||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||
cr := new(containerReference)
|
||||
@@ -229,6 +237,10 @@ type containerReference struct {
|
||||
input *NewContainerInput
|
||||
UID int
|
||||
GID int
|
||||
// attachDone is closed by the attach() streaming goroutine once it has
|
||||
// drained and flushed the container's output. wait() blocks on it so the
|
||||
// tail of the log lands before the step proceeds.
|
||||
attachDone chan struct{}
|
||||
LinuxContainerEnvironmentExtensions
|
||||
}
|
||||
|
||||
@@ -730,7 +742,9 @@ func (cr *containerReference) tryReadGID() common.Executor {
|
||||
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
cmdResponse := make(chan error)
|
||||
// Buffered so the copy goroutine never blocks on send if the grace-period
|
||||
// drain below times out and no one is left to receive.
|
||||
cmdResponse := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
var outWriter io.Writer
|
||||
@@ -749,6 +763,11 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
||||
} else {
|
||||
_, err = io.Copy(outWriter, resp.Reader)
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line so the
|
||||
// final line of a command's output is not lost (e.g. an error message
|
||||
// printed without a trailing newline before the process exits).
|
||||
common.FlushWriter(outWriter)
|
||||
common.FlushWriter(errWriter)
|
||||
cmdResponse <- err
|
||||
}()
|
||||
|
||||
@@ -760,6 +779,16 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
||||
logger.Warnf("Failed to send CTRL+C: %+s", err)
|
||||
}
|
||||
|
||||
// Give the copy goroutine a brief grace period to drain output already
|
||||
// produced by the command before we return, so cancellation does not
|
||||
// truncate the tail of the log. The goroutine exits once the hijacked
|
||||
// stream is closed by resp.Close() in the caller's defer.
|
||||
select {
|
||||
case <-cmdResponse:
|
||||
case <-time.After(drainGracePeriod):
|
||||
logger.Warn("Timed out draining command output after cancellation")
|
||||
}
|
||||
|
||||
// we return the context canceled error to prevent other steps
|
||||
// from executing
|
||||
return ctx.Err()
|
||||
@@ -945,14 +974,23 @@ func (cr *containerReference) attach() common.Executor {
|
||||
if errWriter == nil {
|
||||
errWriter = os.Stderr
|
||||
}
|
||||
done := make(chan struct{})
|
||||
cr.attachDone = done
|
||||
go func() {
|
||||
defer close(done)
|
||||
var copyErr error
|
||||
if !isTerminal || os.Getenv("NORAW") != "" {
|
||||
_, err = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
|
||||
_, copyErr = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
|
||||
} else {
|
||||
_, err = io.Copy(outWriter, out.Reader)
|
||||
_, copyErr = io.Copy(outWriter, out.Reader)
|
||||
}
|
||||
if err != nil {
|
||||
common.Logger(ctx).Error(err)
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line once
|
||||
// the stream reaches EOF, so the final line of the container's
|
||||
// output is not lost when it is not newline-terminated.
|
||||
common.FlushWriter(outWriter)
|
||||
common.FlushWriter(errWriter)
|
||||
if copyErr != nil {
|
||||
common.Logger(ctx).Error(copyErr)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
@@ -991,6 +1029,18 @@ func (cr *containerReference) wait() common.Executor {
|
||||
|
||||
logger.Debugf("Return status: %v", statusCode)
|
||||
|
||||
// The container has exited; wait for the attach() streaming goroutine to
|
||||
// finish draining and flushing its output before returning, so the tail
|
||||
// of the log is not lost. Bounded so a stuck stream cannot hang the step.
|
||||
if cr.attachDone != nil {
|
||||
select {
|
||||
case <-cr.attachDone:
|
||||
case <-time.After(drainGracePeriod):
|
||||
logger.Warn("Timed out draining container output")
|
||||
}
|
||||
cr.attachDone = nil
|
||||
}
|
||||
|
||||
if statusCode == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
@@ -89,6 +91,11 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
|
||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerWaitResult)
|
||||
@@ -206,6 +213,71 @@ func TestDockerExecFailure(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
||||
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
||||
// length) followed by the payload.
|
||||
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||
b := make([]byte, 8+len(payload))
|
||||
b[0] = byte(stream)
|
||||
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
||||
copy(b[8:], payload)
|
||||
return b
|
||||
}
|
||||
|
||||
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
|
||||
// attach() streaming goroutine has drained and flushed the container's output,
|
||||
// so a final line without a trailing newline is not lost.
|
||||
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||
|
||||
var lines []string
|
||||
logWriter := common.NewLineWriter(func(s string) bool {
|
||||
lines = append(lines, s)
|
||||
return true
|
||||
})
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||
Return(mobyclient.ContainerAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
Conn: &mockConn{},
|
||||
Reader: bufio.NewReader(framed),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
statusCh := make(chan container.WaitResponse, 1)
|
||||
statusCh <- container.WaitResponse{StatusCode: 0}
|
||||
errCh := make(chan error, 1)
|
||||
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||
Return(mobyclient.ContainerWaitResult{
|
||||
Result: (<-chan container.WaitResponse)(statusCh),
|
||||
Error: (<-chan error)(errCh),
|
||||
})
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cr.attach()(ctx))
|
||||
require.NoError(t, cr.wait()(ctx))
|
||||
|
||||
// wait() must have blocked until the goroutine drained AND flushed; the
|
||||
// trailing, non-newline-terminated line must therefore be present. Reading
|
||||
// lines here is race-free because wait() synchronizes on attachDone, which
|
||||
// the goroutine closes after the final append.
|
||||
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerWaitFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
"gitea.com/gitea/runner/act/lookpath"
|
||||
"gitea.com/gitea/runner/internal/pkg/process"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
@@ -261,7 +262,7 @@ func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||
cmd.Stdin = tty
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
|
||||
return ppty, tty, nil
|
||||
}
|
||||
|
||||
@@ -321,30 +322,14 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
cmd.Env = envList
|
||||
cmd.Stderr = e.StdOut
|
||||
cmd.Dir = wd
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||
|
||||
// On Windows a step often launches a process tree (a shell that starts a
|
||||
// child which spawns further GUI or background processes). The default
|
||||
// context cancellation only kills the direct child, leaving the rest of the
|
||||
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
||||
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
|
||||
// via a Job Object on cancellation, and bound the wait so a leftover pipe
|
||||
// writer can never hang Wait indefinitely.
|
||||
var killer atomic.Pointer[processKiller]
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd.Cancel = func() error {
|
||||
if k := killer.Load(); k != nil {
|
||||
return k.Kill()
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Once the step process has exited, give its I/O pipes at most this long
|
||||
// to drain before Wait force-closes them and returns (Go's WaitDelay).
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
}
|
||||
// Kill the step's whole process tree on cancellation (a step often launches a
|
||||
// shell that spawns further background or GUI children) and bound the post-exit
|
||||
// I/O wait, so an orphan inheriting cmd's stdout/stderr pipe can never hang
|
||||
// cmd.Wait() and the runner. See process.TreeKill. The PTY path below may
|
||||
// override SysProcAttr, but never touches Cancel/WaitDelay.
|
||||
treeKill := process.NewTreeKill(cmd)
|
||||
|
||||
var ppty *os.File
|
||||
var tty *os.File
|
||||
@@ -375,18 +360,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Assign the started process to a Job Object so cmd.Cancel can kill the
|
||||
// whole descendant tree. Children spawned afterwards are auto-included.
|
||||
// On failure (e.g. nested-job restrictions) we fall back to the default
|
||||
// single-process kill; WaitDelay + end-of-job cleanup still apply.
|
||||
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
|
||||
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||
} else {
|
||||
killer.Store(k)
|
||||
defer k.Close()
|
||||
}
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
@@ -429,6 +407,24 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
|
||||
return parseEnvFile(e, srcPath, env)
|
||||
}
|
||||
|
||||
// removeAll is the filesystem delete used by removeAllWithContext. A package
|
||||
// var so tests can substitute a blocking stub without patching os.RemoveAll.
|
||||
var removeAll = os.RemoveAll
|
||||
|
||||
// removeAllWithContext runs removeAll in a goroutine and returns once it
|
||||
// finishes or ctx is cancelled. On cancellation the goroutine is left running —
|
||||
// a delete blocked inside a syscall cannot be interrupted (see runWithTimeout).
|
||||
func removeAllWithContext(ctx context.Context, path string) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- removeAll(path) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func removePathWithRetry(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
@@ -448,10 +444,13 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
lastErr = os.RemoveAll(path)
|
||||
lastErr = removeAllWithContext(ctx, path)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(lastErr, context.DeadlineExceeded) {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -533,23 +532,61 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// hostCleanupTimeout bounds each filesystem-teardown phase of the host
|
||||
// environment so a single stalled delete cannot wedge the runner slot forever.
|
||||
// A var (not const) so tests can shrink it.
|
||||
var hostCleanupTimeout = 30 * time.Second
|
||||
|
||||
// runWithTimeout runs fn in a goroutine and returns once it finishes or timeout
|
||||
// elapses, whichever comes first. On timeout the goroutine is left running — an
|
||||
// os.RemoveAll blocked inside a delete syscall (AV/EDR filter drivers, an
|
||||
// unresponsive network mount, a dying disk) cannot be interrupted — and
|
||||
// context.DeadlineExceeded is returned. Leaking the goroutine and the scratch
|
||||
// state it was deleting is strictly better than blocking the caller forever and
|
||||
// permanently losing the runner's capacity slot; the leaked scratch dir is
|
||||
// reclaimed later by the runner's idle stale-dir sweep.
|
||||
func runWithTimeout(fn func(), timeout time.Duration) error {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
fn()
|
||||
}()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Ensure any lingering child processes are ended before attempting
|
||||
// to remove the workspace (Windows file locks otherwise prevent cleanup).
|
||||
e.terminateRunningProcesses(ctx)
|
||||
|
||||
// Only removes per-job misc state. Must not remove the cache/toolcache root.
|
||||
// Bound it: CleanUp is a caller-supplied, typically unbounded os.RemoveAll,
|
||||
// and a delete stalled by a filesystem filter driver would otherwise hang
|
||||
// the job forever at "Cleaning up container" and hold the capacity slot.
|
||||
if e.CleanUp != nil {
|
||||
e.CleanUp()
|
||||
logger.Debugf("running host environment cleanup callback")
|
||||
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||
logger.Warnf("host environment cleanup did not finish within %s; continuing job completion, scratch state may be leaked and is reclaimed by the idle stale-dir sweep", hostCleanupTimeout)
|
||||
} else {
|
||||
logger.Debugf("host environment cleanup callback finished")
|
||||
}
|
||||
}
|
||||
|
||||
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
||||
// which absorb Windows file-handle release lag after the kill above.
|
||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), hostCleanupTimeout)
|
||||
defer rmCancel()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
var errs []error
|
||||
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
|
||||
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
||||
@@ -561,8 +598,15 @@ func (e *HostEnvironment) Remove() common.Executor {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
for _, err := range errs {
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
// Bounded teardown timed out; warnings already logged above. Do not
|
||||
// fail job completion — leaked scratch is reclaimed by the idle sweep.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ToContainerPath(path string) string {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -188,6 +189,118 @@ func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
stubDone := make(chan struct{})
|
||||
|
||||
orig := removeAll
|
||||
removeAll = func(string) error {
|
||||
defer close(stubDone)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
// removeAllWithContext intentionally leaks the delete goroutine on timeout,
|
||||
// and that goroutine still references removeAll. Unblock it and wait for it
|
||||
// to return before restoring the var, so the restore can't race the read.
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
<-stubDone
|
||||
removeAll = orig
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := removeAllWithContext(ctx, t.TempDir())
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled
|
||||
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or
|
||||
// an unresponsive mount) wedging the runner slot forever at "Cleaning up
|
||||
// container". Remove must time out the callback and complete job teardown.
|
||||
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
||||
// Keep the suite fast: shrink the per-phase teardown timeout for this test.
|
||||
orig := hostCleanupTimeout
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
CleanUp: func() {
|
||||
<-release // simulate a delete syscall stuck indefinitely
|
||||
},
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.Remove()(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Remove() hung on a stuck CleanUp callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a
|
||||
// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as
|
||||
// #1023) wedging job completion after the CleanUp callback has already timed
|
||||
// out or finished.
|
||||
func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
|
||||
origTimeout := hostCleanupTimeout
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = origTimeout })
|
||||
|
||||
release := make(chan struct{})
|
||||
stubDone := make(chan struct{})
|
||||
|
||||
origRemoveAll := removeAll
|
||||
removeAll = func(string) error {
|
||||
defer close(stubDone)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
// The stuck delete goroutine outlives the timed-out Remove and still reads
|
||||
// removeAll; unblock it and wait before restoring to avoid a restore/read race.
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
<-stubDone
|
||||
removeAll = origRemoveAll
|
||||
})
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.Remove()(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Remove() hung on a stuck path removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
||||
t.Run("single dir", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package container
|
||||
|
||||
import "os"
|
||||
|
||||
// processKiller is a no-op on non-Windows platforms. The Job Object based
|
||||
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
|
||||
// exec.CommandContext cancellation and Setpgid handling apply.
|
||||
type processKiller struct{}
|
||||
|
||||
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
|
||||
|
||||
func (k *processKiller) Kill() error { return nil }
|
||||
|
||||
func (k *processKiller) Close() error { return nil }
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// processKiller terminates a step process together with its entire descendant
|
||||
// tree via a Windows Job Object.
|
||||
//
|
||||
// Background: a step often launches a process tree (a shell that starts a
|
||||
// child which in turn spawns further GUI or background processes). The default
|
||||
// exec.CommandContext cancellation only kills the direct child, so cancelling a
|
||||
// job left the rest of the tree running. Because those orphans inherited the
|
||||
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
|
||||
//
|
||||
// Assigning the step process to a Job Object lets us kill the whole tree
|
||||
// atomically on cancellation (TerminateJobObject), which also closes the
|
||||
// inherited pipe handles so cmd.Wait() can return.
|
||||
type processKiller struct {
|
||||
job windows.Handle
|
||||
}
|
||||
|
||||
// newProcessKiller creates a Job Object and assigns p (an already-started
|
||||
// process) to it. Children spawned by p afterwards are automatically part of
|
||||
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
|
||||
// the handle on normal completion does not kill legitimate background
|
||||
// processes; the tree is only torn down by an explicit Kill (cancellation).
|
||||
func newProcessKiller(p *os.Process) (*processKiller, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
|
||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &processKiller{job: job}, nil
|
||||
}
|
||||
|
||||
// Kill terminates every process currently assigned to the job (the step process
|
||||
// and all of its descendants).
|
||||
func (k *processKiller) Kill() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
return windows.TerminateJobObject(k.job, 1)
|
||||
}
|
||||
|
||||
// Close releases the job handle. It does not terminate the processes.
|
||||
func (k *processKiller) Close() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
h := k.job
|
||||
k.job = 0
|
||||
return windows.CloseHandle(h)
|
||||
}
|
||||
@@ -8,23 +8,10 @@ package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func getSysProcAttr(_ string, tty bool) *syscall.SysProcAttr {
|
||||
if tty {
|
||||
return &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
}
|
||||
}
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return pty.Open()
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Rfork: syscall.RFNOTEG,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -7,13 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{CmdLine: cmdLine, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -48,8 +48,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
if resumeCommand != "" && command != resumeCommand {
|
||||
// There should not be any emojis in the log output for Gitea.
|
||||
// The code in the switch statement is the same.
|
||||
// Return true (not false) so the line still reaches the raw_output
|
||||
// log handler; otherwise everything between ::stop-commands:: and
|
||||
// its end token is silently dropped from the step log.
|
||||
logger.Infof("%s", line)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
arg = UnescapeCommandData(arg)
|
||||
kvPairs = unescapeKvPairs(kvPairs)
|
||||
|
||||
@@ -28,6 +28,29 @@ func TestSetEnv(t *testing.T) {
|
||||
a.Equal("valz", rc.Env["x"])
|
||||
}
|
||||
|
||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := new(RunContext)
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
// Stop command processing until the matching end token is seen.
|
||||
a.True(handler("::stop-commands::my-end-token\n"))
|
||||
|
||||
// A command-shaped line while stopped must not be executed (env unchanged),
|
||||
// but must still return true so it reaches the raw_output log handler and is
|
||||
// not dropped from the step log.
|
||||
a.True(handler("::set-env name=x::valz\n"))
|
||||
a.NotContains(rc.Env, "x")
|
||||
|
||||
// The matching end token resumes command processing.
|
||||
a.True(handler("::my-end-token::\n"))
|
||||
|
||||
// Commands are processed again after resuming.
|
||||
a.True(handler("::set-env name=y::valy\n"))
|
||||
a.Equal("valy", rc.Env["y"])
|
||||
}
|
||||
|
||||
func TestSetOutput(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -462,6 +462,11 @@ func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, execu
|
||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line once the
|
||||
// step has finished, so the final line of the step's output is not lost
|
||||
// when it is not newline-terminated.
|
||||
defer common.FlushWriter(logWriter)
|
||||
|
||||
return executor(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
if job := rc.Run.Job(); job != nil {
|
||||
if container := job.Container(); container != nil {
|
||||
for _, v := range container.Volumes {
|
||||
if rc.ExprEval != nil {
|
||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||
}
|
||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
||||
// Bind anonymous volume or host file.
|
||||
binds = append(binds, v)
|
||||
@@ -471,7 +474,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
rc.pullServicesImages(rc.Config.ForcePull),
|
||||
rc.JobContainer.Pull(rc.Config.ForcePull),
|
||||
rc.stopJobContainer(),
|
||||
container.NewDockerNetworkCreateExecutor(networkName).IfBool(createAndDeleteNetwork),
|
||||
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
||||
IfBool(createAndDeleteNetwork),
|
||||
rc.startServiceContainers(networkName),
|
||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
rc.JobContainer.Start(false),
|
||||
|
||||
@@ -276,6 +276,37 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
||||
}
|
||||
|
||||
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
||||
job := &model.Job{}
|
||||
err := job.RawContainer.Encode(map[string][]string{
|
||||
"volumes": {"${{ secrets.MAME }}:/root/.mame/roms:ro"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := &RunContext{
|
||||
Name: "TestRCName",
|
||||
Run: &model.Run{
|
||||
Workflow: &model.Workflow{
|
||||
Name: "TestWorkflowName",
|
||||
},
|
||||
},
|
||||
Config: &Config{
|
||||
BindWorkdir: false,
|
||||
Secrets: map[string]string{
|
||||
"MAME": "/host/mame/roms",
|
||||
},
|
||||
},
|
||||
}
|
||||
rc.Run.JobID = "job1"
|
||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||
})
|
||||
|
||||
for _, testcase := range tests {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
job := &model.Job{}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
docker_container "github.com/moby/moby/api/types/container"
|
||||
@@ -68,6 +69,7 @@ type Config struct {
|
||||
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
||||
Matrix map[string]map[string]bool // Matrix config to run
|
||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||
|
||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||
|
||||
@@ -312,7 +312,7 @@ func (ra *remoteAction) IsCheckout() bool {
|
||||
|
||||
func newRemoteAction(action string) *remoteAction {
|
||||
// support http(s)://host/owner/repo@v3
|
||||
for _, schema := range []string{"https://", "http://"} {
|
||||
for _, schema := range []string{"https://", "http://", "ssh://"} {
|
||||
if after, ok := strings.CutPrefix(action, schema); ok {
|
||||
splits := strings.SplitN(after, "/", 2)
|
||||
if len(splits) != 2 {
|
||||
|
||||
@@ -778,6 +778,32 @@ func Test_newRemoteAction(t *testing.T) {
|
||||
},
|
||||
wantCloneURL: "http://gitea.com/actions/aws",
|
||||
},
|
||||
{
|
||||
action: "ssh://git@gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it
|
||||
want: &remoteAction{
|
||||
URL: "ssh://git@gitea.com",
|
||||
Org: "actions",
|
||||
Repo: "heroku",
|
||||
Path: "",
|
||||
Ref: "main",
|
||||
},
|
||||
wantCloneURL: "ssh://git@gitea.com/actions/heroku",
|
||||
},
|
||||
{
|
||||
action: "ssh://git@gitea.com/actions/aws/ec2@main", // the ssh user is kept as part of the host segment
|
||||
want: &remoteAction{
|
||||
URL: "ssh://git@gitea.com",
|
||||
Org: "actions",
|
||||
Repo: "aws",
|
||||
Path: "ec2",
|
||||
Ref: "main",
|
||||
},
|
||||
wantCloneURL: "ssh://git@gitea.com/actions/aws",
|
||||
},
|
||||
{
|
||||
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.action, func(t *testing.T) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
||||
155
docs/post-task-script.md
Normal file
155
docs/post-task-script.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Post-task script
|
||||
|
||||
The post-task script is an optional host hook that runs **once after every task**, after the runner has already finished its normal per-task cleanup. Typical uses include pruning Docker images, vacuuming ephemeral disks, or resetting VM state between jobs.
|
||||
|
||||
It is configured under `runner.post_task_script` in the runner YAML config (see [config.example.yaml](../internal/pkg/config/config.example.yaml)).
|
||||
|
||||
## When it runs
|
||||
|
||||
For each task, execution order is:
|
||||
|
||||
1. Workflow runs (steps, actions, containers).
|
||||
2. In-job cleanup (action `post:` steps, container stop/remove).
|
||||
3. Job outputs are reported to Gitea.
|
||||
4. Bind-workdir workspace removal, when `container.bind_workdir` is enabled.
|
||||
5. **Post-task script** (this hook).
|
||||
6. Final task acknowledgement to Gitea (`reporter.Close()`).
|
||||
|
||||
The script is **additive**: it does not replace any built-in cleanup. When `container.bind_workdir` is enabled, the task workspace directory has usually already been deleted before the script starts. `GITEA_WORKSPACE` is still set to the path the job used, for reference.
|
||||
|
||||
## Runner stays offline until the script finishes
|
||||
|
||||
This is the most important operational detail.
|
||||
|
||||
When the post-task script starts, the runner **stops sending task heartbeats** to Gitea (the same mechanism used during cancel/cleanup). From Gitea's perspective, the runner is **not available for new work** until:
|
||||
|
||||
1. The script exits (success or failure), **and**
|
||||
2. The runner sends the final task flush to Gitea.
|
||||
|
||||
While the script runs:
|
||||
|
||||
- **Gitea will not assign another task** to this runner for the current job slot (heartbeats are stopped).
|
||||
- **The runner capacity slot stays occupied** locally — with `capacity: 1`, the poller will not start another task until the script completes.
|
||||
- **Runner shutdown** (`shutdown_timeout`) counts this phase as part of the in-flight task; a long or stuck script delays graceful shutdown.
|
||||
|
||||
If the script **never exits**, the runner remains in this state until `runner.post_task_script_timeout` elapses (default **5 minutes** when a script is configured). The runner then kills the script process and proceeds to the final acknowledgement. Until that timeout fires, **the runner effectively stays offline**.
|
||||
|
||||
Set `post_task_script_timeout` to a value that matches how long your housekeeping is allowed to take — not how long you wish it could take. Prefer short, bounded scripts.
|
||||
|
||||
### Recommendations
|
||||
|
||||
- Keep scripts **fast and bounded** (seconds, not minutes).
|
||||
- Avoid interactive prompts, blocking network calls without timeouts, or waiting on user input.
|
||||
- Use **idempotent** operations (the script may run after success, failure, or cancellation).
|
||||
- Test failure modes: hung script, non-zero exit, missing executable.
|
||||
- Watch the **runner process log** for script output (it is not written to the Gitea job log).
|
||||
- On shutdown, ensure scripts respond to process termination within `post_task_script_timeout`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
# Path to an executable on the host. Empty or omitted disables the hook.
|
||||
post_task_script: /usr/local/bin/gitea-post-task.sh
|
||||
|
||||
# Hard limit on script runtime. Default when post_task_script is set: 5m.
|
||||
# If the script exceeds this, it is killed and the runner continues.
|
||||
post_task_script_timeout: 2m
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `runner.post_task_script` | *(disabled)* | Host path to the script or binary. Relative paths are resolved from the runner process working directory. |
|
||||
| `runner.post_task_script_timeout` | `5m` (only when script is set) | Maximum time the script may run before the runner kills it and moves on. |
|
||||
|
||||
The script must be **executable** on the host (shebang on Linux/macOS, or a native `.exe` / `.bat` / `.cmd` on Windows). **PowerShell (`.ps1`) is not supported yet** as the value of `post_task_script`; the runner executes the configured path directly and does not invoke `powershell.exe` for you.
|
||||
|
||||
`gitea-runner exec` does **not** load runner YAML and will not run this hook.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The script receives `runner.envs` / `runner.env_file` values plus:
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `GITEA_TASK_ID` | Numeric task ID. |
|
||||
| `GITEA_RUN_ID` | Workflow run ID, when provided by the server. |
|
||||
| `GITEA_REPOSITORY` | Repository slug (`owner/name`). |
|
||||
| `GITEA_WORKSPACE` | Workspace path used for the job (may already be deleted). |
|
||||
| `GITEA_JOB_RESULT` | `success`, `failure`, `cancelled`, `skipped`, or `unknown`. |
|
||||
|
||||
The script environment is **not** a full copy of the job container environment. System variables such as `PATH` are only present if you define them in `runner.envs` or `runner.env_file`.
|
||||
|
||||
## Output and errors
|
||||
|
||||
- **Stdout/stderr** are written to the **runner process log** (logrus), prefixed with `post-task script stdout:` / `post-task script stderr:`.
|
||||
- **Non-zero exit codes** are logged as warnings only. They do **not** change the job result already reported to Gitea.
|
||||
- **Timeouts and start failures** are logged as warnings; the runner still completes the task acknowledgement.
|
||||
|
||||
## Interaction with other timeouts
|
||||
|
||||
| Timeout | Effect on post-task script |
|
||||
| --- | --- |
|
||||
| `runner.post_task_script_timeout` | Kills the script if it runs too long. This is the **only** timeout that bounds the script. |
|
||||
| `runner.timeout` | Caps the task **up to** the script. The script detaches from the task deadline, so a job near the runner timeout limit does **not** cut the script short — it still gets its full `post_task_script_timeout`. |
|
||||
| `runner.shutdown_timeout` | On SIGINT/SIGTERM, bounds how long the runner waits for the **task** to finish. The post-task script detaches from cancellation, so it is **not** interrupted by this window and may extend shutdown until its own `post_task_script_timeout` elapses. |
|
||||
|
||||
## Examples
|
||||
|
||||
### Linux — prune dangling Docker resources
|
||||
|
||||
`/usr/local/bin/gitea-post-task.sh`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter 'until=24h'
|
||||
```
|
||||
|
||||
`config.yaml`:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
post_task_script: /usr/local/bin/gitea-post-task.sh
|
||||
post_task_script_timeout: 3m
|
||||
```
|
||||
|
||||
### Windows — batch file (`.cmd`)
|
||||
|
||||
Use a `.cmd` or `.bat` file. PowerShell scripts are **not supported yet** as `post_task_script`; call PowerShell from a batch wrapper if needed:
|
||||
|
||||
`C:\gitea-runner\scripts\post-task.cmd`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
post_task_script: C:\gitea-runner\scripts\post-task.cmd
|
||||
post_task_script_timeout: 3m
|
||||
```
|
||||
|
||||
PowerShell workaround until native `.ps1` support exists:
|
||||
|
||||
`C:\gitea-runner\scripts\post-task.cmd`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0post-task.ps1"
|
||||
```
|
||||
|
||||
## Windows notes
|
||||
|
||||
- Supported as `post_task_script`: `.exe`, `.bat`, `.cmd`.
|
||||
- **Not supported yet:** `.ps1` as the configured path (use a `.cmd` wrapper; see above).
|
||||
- `.sh` files require a Unix shell on the PATH unless you point `post_task_script` at the interpreter.
|
||||
- Use backslashes or forward slashes in YAML paths; both work in Go on Windows.
|
||||
|
||||
## See also
|
||||
|
||||
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
|
||||
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
|
||||
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle
|
||||
12
go.mod
12
go.mod
@@ -5,13 +5,13 @@ go 1.26.0
|
||||
require (
|
||||
connectrpc.com/connect v1.20.0
|
||||
dario.cat/mergo v1.0.2
|
||||
gitea.dev/actions-proto-go v0.5.0
|
||||
gitea.dev/actions-proto-go v0.6.0
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/avast/retry-go/v5 v5.0.0
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v29.5.2+incompatible
|
||||
github.com/docker/cli v29.5.3+incompatible
|
||||
github.com/docker/go-connections v0.7.0
|
||||
github.com/go-git/go-billy/v5 v5.9.0
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
@@ -37,8 +37,8 @@ require (
|
||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gotest.tools/v3 v3.5.2
|
||||
tags.cncf.io/container-device-interface v1.1.0
|
||||
@@ -104,8 +104,8 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
32
go.sum
32
go.sum
@@ -4,8 +4,8 @@ cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
||||
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
gitea.dev/actions-proto-go v0.5.0 h1:Fc3DI4Fm3B3JBRXFUjegql+usoNAjjAw1cxMansfA2I=
|
||||
gitea.dev/actions-proto-go v0.5.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
||||
gitea.dev/actions-proto-go v0.6.0 h1:gjllYQ5vmwlkqOeofTQu5qKTZpmf7kWsafoHvoPCSzY=
|
||||
gitea.dev/actions-proto-go v0.6.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
@@ -47,8 +47,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
|
||||
github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
|
||||
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
||||
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
@@ -147,8 +147,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M=
|
||||
github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||
github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg8wihLBpA=
|
||||
github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
@@ -235,13 +233,13 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -252,16 +250,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@@ -370,6 +371,7 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
|
||||
Version: ver.Version(),
|
||||
Labels: ls,
|
||||
Ephemeral: reg.Ephemeral,
|
||||
Capabilities: run.RunnerCapabilities(),
|
||||
}))
|
||||
if err != nil {
|
||||
log.WithError(err).Error("poller: cannot register new runner")
|
||||
|
||||
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),
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
@@ -33,10 +34,22 @@ import (
|
||||
|
||||
"connectrpc.com/connect"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
docker_container "github.com/moby/moby/api/types/container"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// CapabilityCancelling tells the server this runner understands the
|
||||
// transitional cancelling state and will run post-step cleanup before
|
||||
// finalizing a task as RESULT_CANCELLED.
|
||||
const CapabilityCancelling = "cancelling"
|
||||
|
||||
// RunnerCapabilities are the capability flags this runner advertises to the
|
||||
// server during registration and declaration. The server uses them to enable
|
||||
// transitional features that require runner-side support.
|
||||
func RunnerCapabilities() []string {
|
||||
return []string{CapabilityCancelling}
|
||||
}
|
||||
|
||||
// Runner runs the pipeline.
|
||||
type Runner struct {
|
||||
name string
|
||||
@@ -115,15 +128,22 @@ func (r *Runner) OnIdle(ctx context.Context) {
|
||||
if !r.shouldRunIdleCleanup() {
|
||||
return
|
||||
}
|
||||
// Bind-workdir mode: reclaim stale per-task workspace dirs (numeric task IDs).
|
||||
if r.cfg.Container.BindWorkdir {
|
||||
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
|
||||
workdirRoot := filepath.FromSlash("/" + workdirParent)
|
||||
r.cleanupStaleTaskDirs(ctx, workdirRoot)
|
||||
r.cleanupStaleDirs(ctx, workdirRoot, isTaskIDDir)
|
||||
}
|
||||
// Host mode: reclaim per-job scratch dirs left behind when HostEnvironment
|
||||
// cleanup timed out (e.g. a delete stalled by an AV/EDR filter driver). They
|
||||
// sit under the host workdir parent alongside the shared tool_cache, which
|
||||
// the name match leaves untouched. No-op when no host-mode job ever ran.
|
||||
if hostRoot := filepath.FromSlash(r.cfg.Host.WorkdirParent); hostRoot != "" {
|
||||
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) shouldRunIdleCleanup() bool {
|
||||
if !r.cfg.Container.BindWorkdir {
|
||||
return false
|
||||
}
|
||||
if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 {
|
||||
return false
|
||||
}
|
||||
@@ -143,18 +163,52 @@ func (r *Runner) shouldRunIdleCleanup() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupStaleTaskDirs reclaims stale bind-workdir per-task directories under
|
||||
// workdirRoot. Retained as a thin wrapper so existing callers and tests keep a
|
||||
// stable entry point.
|
||||
func (r *Runner) cleanupStaleTaskDirs(ctx context.Context, workdirRoot string) {
|
||||
entries, err := os.ReadDir(workdirRoot)
|
||||
r.cleanupStaleDirs(ctx, workdirRoot, isTaskIDDir)
|
||||
}
|
||||
|
||||
// isTaskIDDir reports whether name is a per-task workspace dir (numeric task
|
||||
// ID). Any other directory is skipped to avoid deleting operator-managed data
|
||||
// under workdir_root.
|
||||
func isTaskIDDir(name string) bool {
|
||||
_, err := strconv.ParseUint(name, 10, 64)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isHostScratchDir reports whether name is a per-job host-mode scratch dir:
|
||||
// hex.EncodeToString of 8 random bytes, i.e. exactly 16 lowercase hex chars
|
||||
// (see startHostEnvironment in act/runner/run_context.go). The narrow match
|
||||
// leaves the sibling shared "tool_cache" dir and any operator data untouched.
|
||||
func isHostScratchDir(name string) bool {
|
||||
if len(name) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, c := range name {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanupStaleDirs removes immediate child directories of root that match and
|
||||
// whose mtime is older than WorkdirCleanupAge. It is a no-op when root does not
|
||||
// exist yet (the runner has never written there).
|
||||
func (r *Runner) cleanupStaleDirs(ctx context.Context, root string, match func(name string) bool) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
log.Warnf("failed to list task workspace root %s for stale cleanup: %v", workdirRoot, err)
|
||||
log.Warnf("failed to list directory %s for stale cleanup: %v", root, err)
|
||||
return
|
||||
}
|
||||
|
||||
// A task may begin between shouldRunIdleCleanup's running-count check and
|
||||
// the loop below. That is safe because new task dirs are created with the
|
||||
// the loop below. That is safe because new dirs are created with the
|
||||
// current mtime and therefore fall on the keep side of cutoff.
|
||||
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
|
||||
for _, entry := range entries {
|
||||
@@ -164,25 +218,23 @@ func (r *Runner) cleanupStaleTaskDirs(ctx context.Context, workdirRoot string) {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Task workspaces are indexed by numeric task IDs; skip any other
|
||||
// directories to avoid deleting operator-managed data under workdir_root.
|
||||
if _, err := strconv.ParseUint(entry.Name(), 10, 64); err != nil {
|
||||
if !match(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
log.Warnf("failed to stat task workspace %s: %v", filepath.Join(workdirRoot, entry.Name()), err)
|
||||
log.Warnf("failed to stat %s: %v", filepath.Join(root, entry.Name()), err)
|
||||
continue
|
||||
}
|
||||
if info.ModTime().After(cutoff) {
|
||||
continue
|
||||
}
|
||||
taskDir := filepath.Join(workdirRoot, entry.Name())
|
||||
if err := os.RemoveAll(taskDir); err != nil {
|
||||
log.Warnf("failed to clean stale task workspace %s: %v", taskDir, err)
|
||||
dir := filepath.Join(root, entry.Name())
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
log.Warnf("failed to clean stale directory %s: %v", dir, err)
|
||||
continue
|
||||
}
|
||||
log.Infof("cleaned stale task workspace %s", taskDir)
|
||||
log.Infof("cleaned stale directory %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +434,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
CleanWorkdir: true,
|
||||
ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network),
|
||||
ContainerNetworkMode: docker_container.NetworkMode(r.cfg.Container.Network),
|
||||
ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{
|
||||
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
|
||||
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
|
||||
},
|
||||
ContainerOptions: r.cfg.Container.Options,
|
||||
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
||||
Privileged: r.cfg.Container.Privileged,
|
||||
@@ -419,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
|
||||
}
|
||||
|
||||
@@ -506,5 +565,6 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons
|
||||
return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{
|
||||
Version: ver.Version(),
|
||||
Labels: labels,
|
||||
Capabilities: RunnerCapabilities(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -52,6 +52,55 @@ func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
|
||||
assert.DirExists(t, alphaNumericTask)
|
||||
}
|
||||
|
||||
// TestRunnerOnIdleCleansStaleHostScratchDirs covers the host-mode leak path:
|
||||
// a per-job scratch dir (16 hex chars) left behind by a timed-out cleanup must
|
||||
// be reclaimed, while the shared tool_cache and operator data are preserved.
|
||||
func TestRunnerOnIdleCleansStaleHostScratchDirs(t *testing.T) {
|
||||
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
hostRoot := filepath.Join(t.TempDir(), "act")
|
||||
require.NoError(t, os.MkdirAll(hostRoot, 0o700))
|
||||
|
||||
staleScratch := filepath.Join(hostRoot, "0123456789abcdef") // 16 hex
|
||||
freshScratch := filepath.Join(hostRoot, "fedcba9876543210")
|
||||
toolCache := filepath.Join(hostRoot, "tool_cache")
|
||||
operatorData := filepath.Join(hostRoot, "keep-me")
|
||||
for _, path := range []string{staleScratch, freshScratch, toolCache, operatorData} {
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
}
|
||||
require.NoError(t, os.Chtimes(staleScratch, now.Add(-48*time.Hour), now.Add(-48*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(freshScratch, now.Add(-10*time.Minute), now.Add(-10*time.Minute)))
|
||||
require.NoError(t, os.Chtimes(toolCache, now.Add(-72*time.Hour), now.Add(-72*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(operatorData, now.Add(-72*time.Hour), now.Add(-72*time.Hour)))
|
||||
|
||||
r := &Runner{
|
||||
cfg: &config.Config{
|
||||
Host: config.Host{WorkdirParent: hostRoot},
|
||||
Runner: config.Runner{
|
||||
WorkdirCleanupAge: 24 * time.Hour,
|
||||
IdleCleanupInterval: time.Minute,
|
||||
},
|
||||
},
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
r.OnIdle(context.Background())
|
||||
|
||||
assert.NoDirExists(t, staleScratch) // stale scratch reclaimed
|
||||
assert.DirExists(t, freshScratch) // within cleanup age, kept
|
||||
assert.DirExists(t, toolCache) // shared cache, never a scratch match
|
||||
assert.DirExists(t, operatorData) // non-hex name, untouched
|
||||
}
|
||||
|
||||
func TestIsHostScratchDir(t *testing.T) {
|
||||
assert.True(t, isHostScratchDir("0123456789abcdef"))
|
||||
assert.True(t, isHostScratchDir("ffffffffffffffff"))
|
||||
assert.False(t, isHostScratchDir("tool_cache"))
|
||||
assert.False(t, isHostScratchDir("0123456789ABCDEF")) // hex.EncodeToString is lowercase
|
||||
assert.False(t, isHostScratchDir("0123456789abcde")) // 15 chars
|
||||
assert.False(t, isHostScratchDir("0123456789abcdef0")) // 17 chars
|
||||
assert.False(t, isHostScratchDir("123"))
|
||||
}
|
||||
|
||||
func TestRunnerCleanupStaleTaskDirsMissingRoot(t *testing.T) {
|
||||
r := &Runner{
|
||||
cfg: &config.Config{
|
||||
@@ -135,7 +184,10 @@ func TestRunnerShouldRunIdleCleanupSkipsWhenJobRunning(t *testing.T) {
|
||||
assert.False(t, r.shouldRunIdleCleanup())
|
||||
}
|
||||
|
||||
func TestRunnerShouldRunIdleCleanupSkipsWhenBindWorkdirDisabled(t *testing.T) {
|
||||
// Idle cleanup runs regardless of bind_workdir: host mode (bind_workdir off)
|
||||
// still leaves per-job scratch dirs that the sweep must reclaim.
|
||||
func TestRunnerShouldRunIdleCleanupRunsWithoutBindWorkdir(t *testing.T) {
|
||||
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
r := &Runner{
|
||||
cfg: &config.Config{
|
||||
Runner: config.Runner{
|
||||
@@ -143,10 +195,10 @@ func TestRunnerShouldRunIdleCleanupSkipsWhenBindWorkdirDisabled(t *testing.T) {
|
||||
IdleCleanupInterval: time.Minute,
|
||||
},
|
||||
},
|
||||
now: time.Now,
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
assert.False(t, r.shouldRunIdleCleanup())
|
||||
assert.True(t, r.shouldRunIdleCleanup())
|
||||
}
|
||||
|
||||
func TestRunnerShouldRunIdleCleanupSkipsWhenDisabled(t *testing.T) {
|
||||
|
||||
@@ -40,11 +40,12 @@ runner:
|
||||
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
|
||||
# Set to 0 or same as fetch_interval to disable backoff.
|
||||
fetch_interval_max: 5s
|
||||
# While idle, remove stale bind-workdir task directories older than this duration.
|
||||
# Setting either workdir_cleanup_age or idle_cleanup_interval to 0 (or any
|
||||
# non-positive value) disables workdir cleanup entirely.
|
||||
# While idle, remove stale bind-workdir task directories and orphaned host-mode
|
||||
# scratch directories (left behind when a host cleanup delete stalls) older than
|
||||
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
|
||||
# (or any non-positive value) disables stale-directory cleanup entirely.
|
||||
workdir_cleanup_age: 24h
|
||||
# Cadence for the idle stale bind-workdir cleanup pass.
|
||||
# Cadence for the idle stale-directory cleanup pass.
|
||||
idle_cleanup_interval: 10m
|
||||
# The base interval for periodic log flush to the Gitea instance.
|
||||
# Logs may be sent earlier if the buffer reaches log_report_batch_size
|
||||
@@ -82,31 +83,47 @@ runner:
|
||||
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
|
||||
# when a TTY is present.
|
||||
allocate_pty: false
|
||||
# Optional executable on the host, run once after each task's built-in cleanup
|
||||
# (post-steps, container teardown, bind-workdir removal). Additive only.
|
||||
#
|
||||
# IMPORTANT: While this script runs the runner stops task heartbeats and stays
|
||||
# offline from Gitea's perspective until the script exits. A script that never
|
||||
# returns blocks new work until post_task_script_timeout kills it (default 5m).
|
||||
# Keep scripts short; set post_task_script_timeout to a safe upper bound.
|
||||
#
|
||||
# Output -> runner process log (not the job log). Non-zero exit -> warning only.
|
||||
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
|
||||
# the configured path; wrap PowerShell commands in a .cmd file instead.
|
||||
# Full guide: docs/post-task-script.md
|
||||
post_task_script: ''
|
||||
# Hard limit on post_task_script runtime. Default if omitted: 5m.
|
||||
post_task_script_timeout: 5m
|
||||
|
||||
cache:
|
||||
# Enable cache server to use actions/cache.
|
||||
# Enable the built-in cache server (used by actions/cache and similar actions).
|
||||
enabled: true
|
||||
# The directory to store the cache data.
|
||||
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
|
||||
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
||||
# Ignored when external_server is set.
|
||||
dir: ""
|
||||
# The host of the cache server.
|
||||
# It's not for the address to listen, but the address to connect from job containers.
|
||||
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
|
||||
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
||||
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
||||
# Ignored when external_server is set.
|
||||
host: ""
|
||||
# The port of the cache server.
|
||||
# 0 means to use a random available port.
|
||||
# Port for the built-in cache server. 0 picks a random free port.
|
||||
# Ignored when external_server is set.
|
||||
port: 0
|
||||
# The external cache server URL. Valid only when enable is true.
|
||||
# If it's specified, runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# The URL should generally end with "/".
|
||||
# Requires external_secret below to be set to the same value on both this runner and the cache-server.
|
||||
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
|
||||
# Set on every runner that should share a cache pool. Must end with "/".
|
||||
# Example: "http://cache-host:8088/"
|
||||
# Requires external_secret (below) to match the value on the cache-server.
|
||||
external_server: ""
|
||||
# Shared secret between this runner and the external `gitea-runner cache-server`. Required when external_server
|
||||
# (or `gitea-runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the
|
||||
# cache-server, and the cache-server enforces bearer auth + per-repo cache isolation.
|
||||
# Shared secret between this runner and the external cache-server.
|
||||
# Required when external_server is set. Must be identical on every runner and the cache-server.
|
||||
# Generate with: openssl rand -hex 32
|
||||
external_secret: ""
|
||||
# When true, reuse a cached action instead of fetching from the remote on every job. Note: a moved tag
|
||||
# (e.g. a re-tagged "v6") or an updated branch stays at the cached commit until its cache entry is removed.
|
||||
# When true, reuse a cached action instead of fetching from the remote on every job.
|
||||
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
||||
# until its cache entry expires or is manually removed.
|
||||
offline_mode: false
|
||||
|
||||
container:
|
||||
@@ -115,6 +132,13 @@ container:
|
||||
# If it's empty, runner will create a network automatically.
|
||||
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
||||
network: ""
|
||||
# network_create_options only apply when `network` is left empty and the runner
|
||||
# auto-creates a per-job network that does not already exist. They have no effect
|
||||
# when a custom `network` name is set, because that network is used as-is and never
|
||||
# created by the runner. Omit the entire block to use Docker's defaults.
|
||||
network_create_options:
|
||||
enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
|
||||
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||
privileged: false
|
||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
||||
|
||||
@@ -16,6 +16,12 @@ import (
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// DefaultPostTaskScriptTimeout is the fallback cap on how long the post-task
|
||||
// script may run when post_task_script is set without an explicit timeout. It is
|
||||
// applied both at config load (for a configured script) and at the point of use
|
||||
// (so a programmatically built config still gets a sane bound).
|
||||
const DefaultPostTaskScriptTimeout = 5 * time.Minute
|
||||
|
||||
// Log represents the configuration for logging.
|
||||
type Log struct {
|
||||
Level string `yaml:"level"` // Level indicates the logging level.
|
||||
@@ -33,8 +39,8 @@ type Runner struct {
|
||||
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
|
||||
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
|
||||
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
|
||||
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories older than this duration during idle cleanup.
|
||||
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs stale bind-workdir cleanup periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
||||
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories and orphaned host-mode scratch dirs older than this duration during idle cleanup.
|
||||
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs stale-directory cleanup periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
||||
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
|
||||
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
||||
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
||||
@@ -43,6 +49,8 @@ type Runner struct {
|
||||
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
||||
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
||||
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
||||
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
|
||||
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
|
||||
}
|
||||
|
||||
// Cache represents the configuration for caching.
|
||||
@@ -59,6 +67,7 @@ type Cache struct {
|
||||
// Container represents the configuration for the container.
|
||||
type Container struct {
|
||||
Network string `yaml:"network"` // Network specifies the network for the container.
|
||||
NetworkCreateOptions ContainerNetworkCreateOptions `yaml:"network_create_options"` // Add options when the network need to be created by the runner
|
||||
NetworkMode string `yaml:"network_mode"` // Deprecated: use Network instead. Could be removed after Gitea 1.20
|
||||
Privileged bool `yaml:"privileged"` // Privileged indicates whether the container runs in privileged mode.
|
||||
Options string `yaml:"options"` // Options specifies additional options for the container.
|
||||
@@ -72,6 +81,11 @@ type Container struct {
|
||||
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
|
||||
}
|
||||
|
||||
type ContainerNetworkCreateOptions struct {
|
||||
EnableIPv4 *bool `yaml:"enable_ipv4"` // Enable or disable IPv4 for the network (true for docker by default)
|
||||
EnableIPv6 *bool `yaml:"enable_ipv6"` // Enable or disable IPv6 for the network (false for docker by default)
|
||||
}
|
||||
|
||||
// Host represents the configuration for the host.
|
||||
type Host struct {
|
||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
||||
@@ -187,6 +201,9 @@ func LoadDefault(file string) (*Config, error) {
|
||||
if cfg.Runner.ReportCloseTimeout <= 0 {
|
||||
cfg.Runner.ReportCloseTimeout = 10 * time.Second
|
||||
}
|
||||
if cfg.Runner.PostTaskScript != "" && cfg.Runner.PostTaskScriptTimeout <= 0 {
|
||||
cfg.Runner.PostTaskScriptTimeout = DefaultPostTaskScriptTimeout
|
||||
}
|
||||
if cfg.Metrics.Addr == "" {
|
||||
cfg.Metrics.Addr = "127.0.0.1:9101"
|
||||
}
|
||||
|
||||
@@ -107,6 +107,34 @@ runner:
|
||||
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
|
||||
// invalid YAML to the canonical "parse config file" message rather than the
|
||||
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
|
||||
func TestLoadDefault_LoadsPostTaskScript(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
runner:
|
||||
post_task_script: /usr/local/bin/post-task.sh
|
||||
post_task_script_timeout: 2m
|
||||
`), 0o600))
|
||||
|
||||
cfg, err := LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/usr/local/bin/post-task.sh", cfg.Runner.PostTaskScript)
|
||||
assert.Equal(t, 2*time.Minute, cfg.Runner.PostTaskScriptTimeout)
|
||||
}
|
||||
|
||||
func TestLoadDefault_DefaultsPostTaskScriptTimeout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
runner:
|
||||
post_task_script: /usr/local/bin/post-task.sh
|
||||
`), 0o600))
|
||||
|
||||
cfg, err := LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
|
||||
}
|
||||
|
||||
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
@@ -117,3 +145,50 @@ func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "parse config file")
|
||||
assert.NotContains(t, err.Error(), "defaults metadata")
|
||||
}
|
||||
|
||||
func TestContainerNetworkCreateOptions(t *testing.T) {
|
||||
// Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields,
|
||||
// distinguishing an explicit true/false from an omitted key (nil). A nil here is
|
||||
// forwarded as-is to Docker, which applies its own default.
|
||||
loadOptions := func(t *testing.T, yaml string) ContainerNetworkCreateOptions {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600))
|
||||
|
||||
cfg, err := LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
return cfg.Container.NetworkCreateOptions
|
||||
}
|
||||
|
||||
t.Run("enable_ipv6 true unmarshals to non-nil true", func(t *testing.T) {
|
||||
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: true\n")
|
||||
require.NotNil(t, opts.EnableIPv6)
|
||||
assert.True(t, *opts.EnableIPv6)
|
||||
})
|
||||
|
||||
t.Run("enable_ipv6 false unmarshals to non-nil false", func(t *testing.T) {
|
||||
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: false\n")
|
||||
require.NotNil(t, opts.EnableIPv6)
|
||||
assert.False(t, *opts.EnableIPv6)
|
||||
})
|
||||
|
||||
t.Run("enable_ipv4 false unmarshals to non-nil false", func(t *testing.T) {
|
||||
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: false\n")
|
||||
require.NotNil(t, opts.EnableIPv4)
|
||||
assert.False(t, *opts.EnableIPv4)
|
||||
})
|
||||
|
||||
t.Run("omitted keys stay nil", func(t *testing.T) {
|
||||
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: true\n")
|
||||
require.NotNil(t, opts.EnableIPv4)
|
||||
assert.True(t, *opts.EnableIPv4)
|
||||
assert.Nil(t, opts.EnableIPv6, "an omitted enable_ipv6 must remain nil so Docker's default applies")
|
||||
})
|
||||
|
||||
t.Run("omitted block leaves both nil", func(t *testing.T) {
|
||||
opts := loadOptions(t, "container:\n network: \"\"\n")
|
||||
assert.Nil(t, opts.EnableIPv4)
|
||||
assert.Nil(t, opts.EnableIPv6)
|
||||
})
|
||||
}
|
||||
|
||||
29
internal/pkg/process/killer_plan9.go
Normal file
29
internal/pkg/process/killer_plan9.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build plan9
|
||||
|
||||
package process
|
||||
|
||||
import "os"
|
||||
|
||||
// Killer falls back to single-process termination on platforms without a
|
||||
// process-group / Job Object tree-kill. The Job Object (Windows) and process
|
||||
// group (Unix) based tree-kills live in killer_windows.go / killer_unix.go;
|
||||
// here we just kill the direct child, matching the previous default behaviour.
|
||||
type Killer struct {
|
||||
p *os.Process
|
||||
}
|
||||
|
||||
func NewKiller(p *os.Process) (*Killer, error) {
|
||||
return &Killer{p: p}, nil
|
||||
}
|
||||
|
||||
func (k *Killer) Kill() error {
|
||||
if k == nil || k.p == nil {
|
||||
return nil
|
||||
}
|
||||
return k.p.Kill()
|
||||
}
|
||||
|
||||
func (k *Killer) Close() error { return nil }
|
||||
56
internal/pkg/process/killer_unix.go
Normal file
56
internal/pkg/process/killer_unix.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows && !plan9
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Killer terminates a started process together with its whole process group,
|
||||
// which is the Unix counterpart of the Windows Job Object tree-kill.
|
||||
//
|
||||
// Background: a process (a step or a post-task script) often launches a process
|
||||
// tree (a shell that starts a child which in turn spawns further background
|
||||
// processes). The default exec.CommandContext cancellation only kills the
|
||||
// direct child, so cancelling left the rest of the tree running. Because those
|
||||
// orphans inherited the parent's stdout/stderr pipe, cmd.Wait() also blocked
|
||||
// forever and the runner hung.
|
||||
//
|
||||
// Processes are started with Setpgid (or Setsid for the PTY path, see
|
||||
// SysProcAttr), which makes the process the leader of a new process group whose
|
||||
// ID equals its PID. Signalling the negative PID delivers to every process
|
||||
// still in that group, so we can tear down the whole tree atomically on
|
||||
// cancellation, which also closes the inherited pipe handles so cmd.Wait() can
|
||||
// return.
|
||||
type Killer struct {
|
||||
pgid int
|
||||
}
|
||||
|
||||
// NewKiller captures the process group of p (an already-started process).
|
||||
// Because the process is launched with Setpgid/Setsid, p is a group leader and
|
||||
// its PGID equals its PID; children spawned afterwards stay in the same group
|
||||
// unless they explicitly create their own.
|
||||
func NewKiller(p *os.Process) (*Killer, error) {
|
||||
return &Killer{pgid: p.Pid}, nil
|
||||
}
|
||||
|
||||
// Kill sends SIGKILL to the entire process group (the process and every
|
||||
// descendant that stayed in the group). A missing group (ESRCH) means the
|
||||
// processes already exited and is not treated as an error.
|
||||
func (k *Killer) Kill() error {
|
||||
if k == nil || k.pgid <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(-k.pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close is a no-op on Unix; there is no job handle to release.
|
||||
func (k *Killer) Close() error { return nil }
|
||||
101
internal/pkg/process/killer_unix_test.go
Normal file
101
internal/pkg/process/killer_unix_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows && !plan9
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// processAlive reports whether pid refers to a still-running process. Signal 0
|
||||
// performs error checking without delivering a signal: a nil error (or EPERM)
|
||||
// means the process exists, ESRCH means it is gone.
|
||||
//
|
||||
// On Linux, zombie processes (state Z in /proc/<pid>/stat) appear alive to
|
||||
// kill(0) but have already terminated — their corpse lingers until the parent
|
||||
// calls wait(). In a Docker container the child may be reparented to a PID 1
|
||||
// that does not reap promptly, so we treat zombies as not alive.
|
||||
func processAlive(pid int) bool {
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// On Linux /proc is available; check whether the process is a zombie.
|
||||
if b, readErr := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); readErr == nil {
|
||||
// Format: "pid (comm) state ..." — state follows the closing ')' of the
|
||||
// command name (which may itself contain spaces and parens).
|
||||
rest := string(b)
|
||||
if idx := strings.LastIndex(rest, ") "); idx >= 0 {
|
||||
fields := strings.Fields(rest[idx+2:])
|
||||
if len(fields) > 0 && fields[0] == "Z" {
|
||||
return false // zombie: terminated but not yet reaped
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestKillerKillsTree verifies that a process group captured by the killer is
|
||||
// terminated together with a child the process spawns afterwards. This mirrors
|
||||
// a step or post-task script that launches a child which spawns further
|
||||
// processes, where cancelling must take down the whole tree, not just the
|
||||
// direct child.
|
||||
func TestKillerKillsTree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pidFile := filepath.Join(dir, "child.pid")
|
||||
|
||||
// Parent shell backgrounds a long-lived child (writing its PID to a file)
|
||||
// and then sleeps. With job control off (non-interactive sh) the backgrounded
|
||||
// child stays in the parent's process group, so the group kill must reach it.
|
||||
script := fmt.Sprintf(`sleep 600 & echo $! > %q; sleep 600`, pidFile)
|
||||
cmd := exec.Command("/bin/sh", "-c", script)
|
||||
// Launch as its own process-group leader, exactly like a real process does
|
||||
// (see SysProcAttr), so the killer's PGID == the process PID.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
require.NoError(t, cmd.Start())
|
||||
t.Cleanup(func() {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
killer, err := NewKiller(cmd.Process)
|
||||
require.NoError(t, err)
|
||||
defer killer.Close()
|
||||
|
||||
// Wait for the backgrounded child PID to be reported.
|
||||
var childPID int
|
||||
require.Eventually(t, func() bool {
|
||||
b, e := os.ReadFile(pidFile)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
childPID, _ = strconv.Atoi(s)
|
||||
return childPID > 0 && processAlive(childPID)
|
||||
}, 20*time.Second, 100*time.Millisecond, "child process should start")
|
||||
|
||||
// Killing the group must terminate both the parent and the backgrounded child.
|
||||
require.NoError(t, killer.Kill())
|
||||
// Reap the parent so it does not linger as a zombie (which would still report
|
||||
// as alive); SIGKILL makes Wait return promptly.
|
||||
_ = cmd.Wait()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return !processAlive(childPID)
|
||||
}, 20*time.Second, 100*time.Millisecond, "backgrounded child should be terminated")
|
||||
}
|
||||
72
internal/pkg/process/killer_windows.go
Normal file
72
internal/pkg/process/killer_windows.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Killer terminates a started process together with its entire descendant tree
|
||||
// via a Windows Job Object.
|
||||
//
|
||||
// Background: a process (a step or a post-task script) often launches a process
|
||||
// tree (a shell that starts a child which in turn spawns further GUI or
|
||||
// background processes). The default exec.CommandContext cancellation only kills
|
||||
// the direct child, so cancelling left the rest of the tree running. Because
|
||||
// those orphans inherited the parent's stdout/stderr pipe, cmd.Wait() also
|
||||
// blocked forever and the runner hung.
|
||||
//
|
||||
// Assigning the process to a Job Object lets us kill the whole tree atomically
|
||||
// on cancellation (TerminateJobObject), which also closes the inherited pipe
|
||||
// handles so cmd.Wait() can return.
|
||||
type Killer struct {
|
||||
job windows.Handle
|
||||
}
|
||||
|
||||
// NewKiller creates a Job Object and assigns p (an already-started process) to
|
||||
// it. Children spawned by p afterwards are automatically part of the job. The
|
||||
// job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing the handle on
|
||||
// normal completion does not kill legitimate background processes; the tree is
|
||||
// only torn down by an explicit Kill (cancellation).
|
||||
func NewKiller(p *os.Process) (*Killer, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
|
||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Killer{job: job}, nil
|
||||
}
|
||||
|
||||
// Kill terminates every process currently assigned to the job (the process and
|
||||
// all of its descendants).
|
||||
func (k *Killer) Kill() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
return windows.TerminateJobObject(k.job, 1)
|
||||
}
|
||||
|
||||
// Close releases the job handle. It does not terminate the processes.
|
||||
func (k *Killer) Close() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
h := k.job
|
||||
k.job = 0
|
||||
return windows.CloseHandle(h)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -32,11 +32,11 @@ func processAlive(pid int) bool {
|
||||
return code == stillActive
|
||||
}
|
||||
|
||||
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
|
||||
// is terminated together with a child it spawns afterwards. This mirrors a step
|
||||
// that launches a child which spawns further processes, where cancelling the
|
||||
// job must take down the whole tree, not just the direct child.
|
||||
func TestProcessKillerKillsTree(t *testing.T) {
|
||||
// TestKillerKillsTree verifies that a process assigned to the Job Object is
|
||||
// terminated together with a child it spawns afterwards. This mirrors a step or
|
||||
// post-task script that launches a child which spawns further processes, where
|
||||
// cancelling must take down the whole tree, not just the direct child.
|
||||
func TestKillerKillsTree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pidFile := filepath.Join(dir, "child.pid")
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestProcessKillerKillsTree(t *testing.T) {
|
||||
require.NoError(t, cmd.Start())
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||
|
||||
killer, err := newProcessKiller(cmd.Process)
|
||||
killer, err := NewKiller(cmd.Process)
|
||||
require.NoError(t, err)
|
||||
defer killer.Close()
|
||||
|
||||
17
internal/pkg/process/sysprocattr_plan9.go
Normal file
17
internal/pkg/process/sysprocattr_plan9.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build plan9
|
||||
|
||||
package process
|
||||
|
||||
import "syscall"
|
||||
|
||||
// SysProcAttr returns the platform attributes used to start a process. Plan 9
|
||||
// has no process-group tree-kill (see Killer), so we only request a new rfork
|
||||
// note group here.
|
||||
func SysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Rfork: syscall.RFNOTEG,
|
||||
}
|
||||
}
|
||||
24
internal/pkg/process/sysprocattr_unix.go
Normal file
24
internal/pkg/process/sysprocattr_unix.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows && !plan9
|
||||
|
||||
package process
|
||||
|
||||
import "syscall"
|
||||
|
||||
// SysProcAttr returns the platform attributes used to start a process so that a
|
||||
// Killer can later tear down its whole process tree. On Unix the process becomes
|
||||
// the leader of a new process group (or session, for the PTY path), so a
|
||||
// signal to the negative PID reaches every descendant that stayed in the group.
|
||||
func SysProcAttr(_ string, tty bool) *syscall.SysProcAttr {
|
||||
if tty {
|
||||
return &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
}
|
||||
}
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
14
internal/pkg/process/sysprocattr_windows.go
Normal file
14
internal/pkg/process/sysprocattr_windows.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package process
|
||||
|
||||
import "syscall"
|
||||
|
||||
// SysProcAttr returns the platform attributes used to start a process so that a
|
||||
// Killer can later tear down its whole process tree. On Windows the process is
|
||||
// placed in a new process group; the descendant tree is reclaimed via the Job
|
||||
// Object set up by NewKiller.
|
||||
func SysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{CmdLine: cmdLine, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
66
internal/pkg/process/treekill.go
Normal file
66
internal/pkg/process/treekill.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// treeKillWaitDelay bounds how long Wait lingers for the command's I/O pipes to
|
||||
// drain after the process exits before force-closing them and returning. It also
|
||||
// covers a command that backgrounds a process holding a pipe open after a clean
|
||||
// exit.
|
||||
const treeKillWaitDelay = 10 * time.Second
|
||||
|
||||
// TreeKill wires an exec.Cmd so that cancelling it tears down the command's
|
||||
// whole process tree (see Killer) rather than only the direct child, and bounds
|
||||
// the post-exit I/O wait so a leftover pipe writer can never hang cmd.Wait.
|
||||
//
|
||||
// Background: a command often launches a process tree (a shell that starts a
|
||||
// child which spawns further background processes). The default
|
||||
// exec.CommandContext cancellation only kills the direct child, leaving the rest
|
||||
// of the tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
||||
// cmd.Wait() would block forever, hanging the caller.
|
||||
//
|
||||
// Callers still set cmd.SysProcAttr (via SysProcAttr) themselves, because the
|
||||
// value differs between the plain and PTY execution paths.
|
||||
type TreeKill struct {
|
||||
killer atomic.Pointer[Killer]
|
||||
}
|
||||
|
||||
// NewTreeKill sets cmd.Cancel and cmd.WaitDelay. Call it before cmd.Start, then
|
||||
// call Capture once after a successful Start.
|
||||
func NewTreeKill(cmd *exec.Cmd) *TreeKill {
|
||||
t := &TreeKill{}
|
||||
cmd.Cancel = func() error {
|
||||
if k := t.killer.Load(); k != nil {
|
||||
return k.Kill()
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = treeKillWaitDelay
|
||||
return t
|
||||
}
|
||||
|
||||
// Capture assigns the started process (and the descendants it spawns) to a
|
||||
// Killer so cancellation can reach the whole tree — a Job Object on Windows
|
||||
// (children spawned afterwards are auto-included) and the process group on Unix.
|
||||
// Call it once after cmd.Start. On failure the command falls back to the default
|
||||
// single-process kill and the returned error is for logging only; WaitDelay
|
||||
// still bounds the wait. The returned Killer should be closed when the command
|
||||
// finishes (Close is nil-safe).
|
||||
func (t *TreeKill) Capture(p *os.Process) (*Killer, error) {
|
||||
k, err := NewKiller(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.killer.Store(k)
|
||||
return k, nil
|
||||
}
|
||||
@@ -49,6 +49,8 @@ type Reporter struct {
|
||||
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.
|
||||
@@ -100,6 +102,7 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
|
||||
Id: task.Id,
|
||||
},
|
||||
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()
|
||||
@@ -391,15 +409,28 @@ func (r *Reporter) Close(lastWords string) error {
|
||||
r.stateMu.Lock()
|
||||
r.closed = true
|
||||
if r.state.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
// When r.ctx has been cancelled (server returned RESULT_CANCELLED via
|
||||
// rpcCtx/ReportState, see line 590) the job is being torn down on the
|
||||
// cancellation path: surface that explicitly instead of attributing it
|
||||
// to a generic failure.
|
||||
cancelled := errors.Is(r.ctx.Err(), context.Canceled)
|
||||
if lastWords == "" {
|
||||
if cancelled {
|
||||
lastWords = "Cancelled"
|
||||
} else {
|
||||
lastWords = "Early termination"
|
||||
}
|
||||
}
|
||||
for _, v := range r.state.Steps {
|
||||
if v.Result == runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
v.Result = runnerv1.Result_RESULT_CANCELLED
|
||||
}
|
||||
}
|
||||
if cancelled {
|
||||
r.state.Result = runnerv1.Result_RESULT_CANCELLED
|
||||
} else {
|
||||
r.state.Result = runnerv1.Result_RESULT_FAILURE
|
||||
}
|
||||
r.logRows = append(r.logRows, &runnerv1.LogRow{
|
||||
Time: timestamppb.Now(),
|
||||
Content: lastWords,
|
||||
|
||||
@@ -850,3 +850,136 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
|
||||
assert.True(t, finalLogNoMoreSeen.Load(), "Close() must send a final UpdateLog{NoMore:true} even after server-side cancellation")
|
||||
assert.True(t, finalTaskStateSeen.Load(), "Close() must send a final UpdateTask with the populated final state even after server-side cancellation")
|
||||
}
|
||||
|
||||
// TestReporter_CloseReportsCancelledOnCanceledCtx asserts that when Close()
|
||||
// runs on a reporter whose state has not been finalised AND whose context has
|
||||
// been cancelled, the synthesised final state carries RESULT_CANCELLED and
|
||||
// the appended log row reads "Cancelled" — not RESULT_FAILURE / "Early
|
||||
// termination". This is the runner-side half of the Running -> Cancelling ->
|
||||
// Cancelled flow: it gives Gitea an explicit cancel acknowledgement rather
|
||||
// than a generic failure when the job is torn down on the cancel path.
|
||||
func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
|
||||
var finalState atomic.Pointer[runnerv1.TaskState]
|
||||
var finalLogRows atomic.Pointer[[]*runnerv1.LogRow]
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
|
||||
if req.Msg.NoMore {
|
||||
rows := append([]*runnerv1.LogRow(nil), req.Msg.Rows...)
|
||||
finalLogRows.Store(&rows)
|
||||
}
|
||||
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, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
if req.Msg.State != nil && req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
finalState.Store(req.Msg.State)
|
||||
}
|
||||
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(1)
|
||||
|
||||
// Simulate the cancellation path: r.ctx is cancelled before Close() runs.
|
||||
cancel()
|
||||
|
||||
// Skip the daemon wait inside Close().
|
||||
close(reporter.daemon)
|
||||
|
||||
// Empty lastWords so Close() picks the synthesised value.
|
||||
require.NoError(t, reporter.Close(""))
|
||||
|
||||
got := finalState.Load()
|
||||
require.NotNil(t, got, "Close() must send a final UpdateTask")
|
||||
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, got.Result,
|
||||
"final Result must be RESULT_CANCELLED when r.ctx is cancelled, not RESULT_FAILURE")
|
||||
require.Len(t, got.Steps, 1)
|
||||
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, got.Steps[0].Result,
|
||||
"unfinished steps must be marked RESULT_CANCELLED")
|
||||
|
||||
rows := finalLogRows.Load()
|
||||
require.NotNil(t, rows, "Close() must send a final UpdateLog{NoMore:true}")
|
||||
var foundCancelled, foundEarlyTermination bool
|
||||
for _, r := range *rows {
|
||||
if r.Content == "Cancelled" {
|
||||
foundCancelled = true
|
||||
}
|
||||
if r.Content == "Early termination" {
|
||||
foundEarlyTermination = true
|
||||
}
|
||||
}
|
||||
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