mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-07-22 20:47:45 +00:00
Compare commits
23 Commits
fix/659-cl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6133d64270 | ||
|
|
c43cbe87ca | ||
|
|
7bec310002 | ||
|
|
8af385d147 | ||
|
|
46f22c78d2 | ||
|
|
068afc3996 | ||
|
|
0e8896c52a | ||
|
|
89467c9dd0 | ||
|
|
0c08b0f2da | ||
|
|
aa7a29a157 | ||
|
|
ad967330a8 | ||
|
|
60177008a5 | ||
|
|
58c5eb8d21 | ||
|
|
d6882b3df5 | ||
|
|
7e7e3ef1a6 | ||
|
|
16357a34b2 | ||
|
|
d53538ac38 | ||
|
|
554b3b7671 | ||
|
|
65756d60b3 | ||
|
|
be9b4502d6 | ||
|
|
1d74ae636a | ||
|
|
b12d02c25f | ||
|
|
f2e0cf9131 |
@@ -19,7 +19,7 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
- run: make lint-pr-title
|
- run: make lint-pr-title
|
||||||
|
|||||||
@@ -43,12 +43,18 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
variant:
|
variant:
|
||||||
|
# The basic image is built from source and can target any arch the
|
||||||
|
# toolchain supports. The dind variants are limited to the arches the
|
||||||
|
# docker:dind base image publishes.
|
||||||
- target: basic
|
- target: basic
|
||||||
tag_suffix: ""
|
tag_suffix: ""
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||||
- target: dind
|
- target: dind
|
||||||
tag_suffix: "-dind"
|
tag_suffix: "-dind"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
- target: dind-rootless
|
- target: dind-rootless
|
||||||
tag_suffix: "-dind-rootless"
|
tag_suffix: "-dind-rootless"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -82,9 +88,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
target: ${{ matrix.variant.target }}
|
target: ${{ matrix.variant.target }}
|
||||||
platforms: |
|
platforms: ${{ matrix.variant.platforms }}
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
||||||
|
|||||||
@@ -42,12 +42,18 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
variant:
|
variant:
|
||||||
|
# The basic image is built from source and can target any arch the
|
||||||
|
# toolchain supports. The dind variants are limited to the arches the
|
||||||
|
# docker:dind base image publishes.
|
||||||
- target: basic
|
- target: basic
|
||||||
tag_suffix: ""
|
tag_suffix: ""
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||||
- target: dind
|
- target: dind
|
||||||
tag_suffix: "-dind"
|
tag_suffix: "-dind"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
- target: dind-rootless
|
- target: dind-rootless
|
||||||
tag_suffix: "-dind-rootless"
|
tag_suffix: "-dind-rootless"
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
@@ -91,9 +97,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
target: ${{ matrix.variant.target }}
|
target: ${{ matrix.variant.target }}
|
||||||
platforms: |
|
platforms: ${{ matrix.variant.platforms }}
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -85,6 +85,8 @@ 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)).
|
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)).
|
||||||
|
|
||||||
|
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
|
||||||
|
|
||||||
### Image flavours
|
### 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.
|
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.
|
||||||
@@ -121,6 +123,8 @@ Two processes have to run side by side here (the Docker daemon and the runner),
|
|||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user.
|
||||||
|
|
||||||
> **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.
|
> **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
|
### Configuration
|
||||||
@@ -143,23 +147,68 @@ Every option is described in [config.example.yaml](internal/pkg/config/config.ex
|
|||||||
|
|
||||||
#### Without a config file
|
#### Without a config file
|
||||||
|
|
||||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document). A small set of **deprecated** environment variables can still override parts of that default config, but **only when no `-c` path was given**; they are ignored if you use a config file:
|
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
|
||||||
|
|
||||||
| Variable | Effect |
|
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
Labels decide **which jobs a runner accepts** and **how it runs them**. A job's `runs-on` is matched against the runner's label names; the first match wins and selects the execution environment for that job.
|
||||||
|
|
||||||
|
A label is written as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<name>[:<schema>[:<args>]]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Part | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GITEA_DEBUG` | If true, sets log level to `debug` |
|
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||||
| `GITEA_TRACE` | If true, sets log level to `trace` |
|
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
||||||
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) |
|
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
||||||
| `GITEA_RUNNER_FILE` | Registration state file path (default `.runner`) |
|
|
||||||
| `GITEA_RUNNER_ENVIRON` | Extra job env vars as comma-separated `KEY:VALUE` pairs |
|
|
||||||
| `GITEA_RUNNER_ENV_FILE` | Path to an env file merged into job env (same idea as `runner.env_file` in YAML) |
|
|
||||||
|
|
||||||
Prefer a YAML file for all settings.
|
Two schemas are supported:
|
||||||
|
|
||||||
|
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
|
||||||
|
|
||||||
|
```text
|
||||||
|
macos:host
|
||||||
|
```
|
||||||
|
|
||||||
|
So with the labels
|
||||||
|
|
||||||
|
```text
|
||||||
|
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
||||||
|
```
|
||||||
|
|
||||||
|
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
||||||
|
|
||||||
|
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||||
|
|
||||||
|
If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||||
|
|
||||||
|
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||||
|
|
||||||
#### Registration vs config labels
|
#### Registration vs config labels
|
||||||
|
|
||||||
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
|
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
|
||||||
|
|
||||||
|
The `daemon` command also accepts `--labels` (which defaults to the `GITEA_RUNNER_LABELS` environment variable), so the labels of an already registered runner can be changed without deleting its registration file. The most explicit source wins:
|
||||||
|
|
||||||
|
```
|
||||||
|
--labels / GITEA_RUNNER_LABELS > runner.labels in the config file > labels in the .runner file
|
||||||
|
```
|
||||||
|
|
||||||
|
Whenever the resulting labels differ from the ones in the registration file, they are written back to it and re-declared to the Gitea instance on startup.
|
||||||
|
|
||||||
|
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
|
||||||
|
|
||||||
#### Caching (`actions/cache`)
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||||
|
|||||||
@@ -261,6 +261,10 @@ type NewGitCloneExecutorInput struct {
|
|||||||
// 0 for full clone.
|
// 0 for full clone.
|
||||||
Depth int
|
Depth int
|
||||||
|
|
||||||
|
// Quiet drops the informational clone line to debug level, for callers that log their own
|
||||||
|
// download summary (the setup section's action report).
|
||||||
|
Quiet bool
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
}
|
}
|
||||||
@@ -347,7 +351,11 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
|||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
if input.Quiet {
|
||||||
|
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
|
} else {
|
||||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
|
}
|
||||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||||
|
|
||||||
defer AcquireCloneLock(input.Dir)()
|
defer AcquireCloneLock(input.Dir)()
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -404,6 +408,44 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
// Quiet callers report the download themselves, so the clone line must not reach the job log.
|
||||||
|
for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
logger.SetLevel(log.InfoLevel)
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1"))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: t.TempDir(),
|
||||||
|
Quiet: quiet,
|
||||||
|
})(ctx))
|
||||||
|
|
||||||
|
var cloneLines int
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
if strings.HasPrefix(entry.Message, "git clone ") {
|
||||||
|
cloneLines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quiet {
|
||||||
|
assert.Zero(t, cloneLines)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, cloneLines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorShallow(t *testing.T) {
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||||
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
|
|
||||||
_ = logDockerResponse(logger, reader, err != nil)
|
_ = logDockerResponse(logger, reader, err != nil)
|
||||||
}
|
}
|
||||||
return err
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,6 +376,11 @@ func (cr *containerReference) find() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isContainerGone reports whether a failed remove still left the container gone (NotFound or Conflict).
|
||||||
|
func isContainerGone(err error) bool {
|
||||||
|
return cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err)
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *containerReference) remove() common.Executor {
|
func (cr *containerReference) remove() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
@@ -387,7 +392,7 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil && !isContainerGone(err) {
|
||||||
logger.Error(fmt.Errorf("failed to remove container: %w", err))
|
logger.Error(fmt.Errorf("failed to remove container: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.Co
|
|||||||
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
type endlessReader struct {
|
type endlessReader struct {
|
||||||
io.Reader
|
io.Reader
|
||||||
}
|
}
|
||||||
@@ -381,6 +386,40 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||||
|
// be logged as one.
|
||||||
|
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||||
|
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantLogs bool
|
||||||
|
}{
|
||||||
|
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress")},
|
||||||
|
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||||
|
{name: "removed cleanly", err: nil},
|
||||||
|
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantLogs: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
logger, hook := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||||
|
cr := &containerReference{id: "abc", cli: client}
|
||||||
|
|
||||||
|
require.NoError(t, cr.remove()(ctx))
|
||||||
|
assert.Empty(t, cr.id)
|
||||||
|
|
||||||
|
if tc.wantLogs {
|
||||||
|
assert.Len(t, hook.AllEntries(), 1)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, hook.AllEntries())
|
||||||
|
}
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||||
// daemon with a torn-down container.
|
// daemon with a torn-down container.
|
||||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -43,6 +44,25 @@ type HostEnvironment struct {
|
|||||||
CleanUp func()
|
CleanUp func()
|
||||||
StdOut io.Writer
|
StdOut io.Writer
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
|
|
||||||
|
// procGroup owns every process the job's steps start. Atomic: Remove may read
|
||||||
|
// it while a step is still starting.
|
||||||
|
procGroupOnce sync.Once
|
||||||
|
procGroup atomic.Pointer[process.Group]
|
||||||
|
}
|
||||||
|
|
||||||
|
// processGroup returns the job-scoped process group, creating it on first use.
|
||||||
|
// Returns nil if the job object could not be created; Group is nil-safe.
|
||||||
|
func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
|
||||||
|
e.procGroupOnce.Do(func() {
|
||||||
|
group, err := process.NewGroup()
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.procGroup.Store(group)
|
||||||
|
})
|
||||||
|
return e.procGroup.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||||
@@ -324,11 +344,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
cmd.Dir = wd
|
cmd.Dir = wd
|
||||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||||
|
|
||||||
// Kill the step's whole process tree on cancellation (a step often launches a
|
// Kills the step's whole tree on cancellation and bounds the post-exit I/O
|
||||||
// shell that spawns further background or GUI children) and bound the post-exit
|
// wait, so an orphan holding cmd's stdout pipe cannot hang cmd.Wait().
|
||||||
// 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)
|
treeKill := process.NewTreeKill(cmd)
|
||||||
|
|
||||||
var ppty *os.File
|
var ppty *os.File
|
||||||
@@ -360,6 +377,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Assign before the step's Killer so the step's job nests inside the group's;
|
||||||
|
// cancellation still scopes to this step's tree.
|
||||||
|
if err := e.processGroup(ctx).Assign(cmd.Process); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("could not assign the step's process to the job's process group; a process it leaves behind may outlive the job: %v", err)
|
||||||
|
}
|
||||||
if k, kerr := treeKill.Capture(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)
|
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||||
} else {
|
} else {
|
||||||
@@ -407,13 +429,12 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
|
|||||||
return parseEnvFile(e, srcPath, env)
|
return parseEnvFile(e, srcPath, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeAll is the filesystem delete used by removeAllWithContext. A package
|
// removeAll is a var so tests can substitute a blocking stub.
|
||||||
// var so tests can substitute a blocking stub without patching os.RemoveAll.
|
|
||||||
var removeAll = os.RemoveAll
|
var removeAll = os.RemoveAll
|
||||||
|
|
||||||
// removeAllWithContext runs removeAll in a goroutine and returns once it
|
// removeAllWithContext returns once the delete finishes or ctx is cancelled. On
|
||||||
// finishes or ctx is cancelled. On cancellation the goroutine is left running —
|
// cancellation the goroutine leaks: a delete inside a syscall cannot be
|
||||||
// a delete blocked inside a syscall cannot be interrupted (see runWithTimeout).
|
// interrupted (see runWithTimeout).
|
||||||
func removeAllWithContext(ctx context.Context, path string) error {
|
func removeAllWithContext(ctx context.Context, path string) error {
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- removeAll(path) }()
|
go func() { done <- removeAll(path) }()
|
||||||
@@ -455,17 +476,12 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
|||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill
|
// buildWindowsWorkspaceKillScript builds a PowerShell command that taskkills
|
||||||
// /T /F`s every process tree whose ExecutablePath or CommandLine references one
|
// every process tree whose ExecutablePath or CommandLine references one of the
|
||||||
// of the given absolute workspace dirs, releasing file handles for cleanup.
|
// given workspace dirs, releasing file handles for cleanup. Win32_Process
|
||||||
//
|
// exposes both fields (Get-Process doesn't, wmic is deprecated); matching is on
|
||||||
// Win32_Process is used because it exposes both ExecutablePath and CommandLine
|
// the dir+separator prefix via ordinal String methods, so a name-prefix sibling
|
||||||
// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator
|
// (job1 vs job10) is spared and path metacharacters stay literal.
|
||||||
// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared.
|
|
||||||
// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay
|
|
||||||
// literal.
|
|
||||||
//
|
|
||||||
// Pure function so the quote-escaping can be unit-tested without PowerShell.
|
|
||||||
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
||||||
quoted := make([]string, len(dirs))
|
quoted := make([]string, len(dirs))
|
||||||
for i, d := range dirs {
|
for i, d := range dirs {
|
||||||
@@ -501,9 +517,8 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
|||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// Workspace dirs we own. Any process running from or referencing one is a
|
// Dirs we own; a process referencing one is a leftover. ToolCache is shared
|
||||||
// leftover job process. ToolCache is shared across jobs; Workdir only when
|
// across jobs, and Workdir may be a caller-owned checkout.
|
||||||
// we own it (else it's a caller-provided checkout, e.g. act local mode).
|
|
||||||
owned := []string{e.Path, e.TmpDir}
|
owned := []string{e.Path, e.TmpDir}
|
||||||
if e.CleanWorkdir {
|
if e.CleanWorkdir {
|
||||||
owned = append(owned, e.Workdir)
|
owned = append(owned, e.Workdir)
|
||||||
@@ -530,21 +545,24 @@ func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Win32_Process exposes no working directory, so the scan above misses a
|
||||||
|
// process that merely runs in a workspace dir while pinning a handle on it.
|
||||||
|
if killed, err := process.KillProcessesWithCWDUnder(killCtx, dirs); err != nil {
|
||||||
|
logger.Debugf("workspace process kill by working directory reported errors: %v", err)
|
||||||
|
} else if killed > 0 {
|
||||||
|
logger.Debugf("terminated %d leftover process(es) by workspace working directory", killed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostCleanupTimeout bounds each filesystem-teardown phase of the host
|
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
|
||||||
// environment so a single stalled delete cannot wedge the runner slot forever.
|
// wedge the runner slot. A var so tests can shrink it.
|
||||||
// A var (not const) so tests can shrink it.
|
|
||||||
var hostCleanupTimeout = 30 * time.Second
|
var hostCleanupTimeout = 30 * time.Second
|
||||||
|
|
||||||
// runWithTimeout runs fn in a goroutine and returns once it finishes or timeout
|
// runWithTimeout returns context.DeadlineExceeded once timeout elapses, leaking
|
||||||
// elapses, whichever comes first. On timeout the goroutine is left running — an
|
// the goroutine: a delete blocked in a syscall (AV filter driver, dead network
|
||||||
// os.RemoveAll blocked inside a delete syscall (AV/EDR filter drivers, an
|
// mount) cannot be interrupted, and leaking scratch state beats losing the
|
||||||
// unresponsive network mount, a dying disk) cannot be interrupted — and
|
// runner's capacity slot forever. The idle stale-dir sweep reclaims it later.
|
||||||
// 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 {
|
func runWithTimeout(fn func(), timeout time.Duration) error {
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -565,14 +583,15 @@ func (e *HostEnvironment) Remove() common.Executor {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// Ensure any lingering child processes are ended before attempting
|
// End lingering processes before removing the workspace; on Windows their
|
||||||
// to remove the workspace (Windows file locks otherwise prevent cleanup).
|
// file locks block cleanup. Closing the group is deterministic, the scan a net.
|
||||||
|
if err := e.procGroup.Load().Close(); err != nil {
|
||||||
|
logger.Debugf("closing the job's process group failed: %v", err)
|
||||||
|
}
|
||||||
e.terminateRunningProcesses(ctx)
|
e.terminateRunningProcesses(ctx)
|
||||||
|
|
||||||
// Only removes per-job misc state. Must not remove the cache/toolcache root.
|
// Removes per-job misc state only, never the toolcache root. Bounded because
|
||||||
// Bound it: CleanUp is a caller-supplied, typically unbounded os.RemoveAll,
|
// 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 {
|
if e.CleanUp != nil {
|
||||||
logger.Debugf("running host environment cleanup callback")
|
logger.Debugf("running host environment cleanup callback")
|
||||||
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||||
@@ -603,8 +622,7 @@ func (e *HostEnvironment) Remove() common.Executor {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bounded teardown timed out; warnings already logged above. Do not
|
// Teardown timed out; warned above. Do not fail job completion over it.
|
||||||
// fail job completion — leaked scratch is reclaimed by the idle sweep.
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
||||||
@@ -28,11 +31,19 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s := bufio.NewScanner(reader)
|
// Decode by BOM: Windows PowerShell 5.1 redirection writes UTF-16, and some
|
||||||
|
// tools emit a UTF-8 BOM. Without a BOM the file is read as UTF-8, as before.
|
||||||
|
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
|
||||||
|
|
||||||
|
s := bufio.NewScanner(decoded)
|
||||||
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
|
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
|
||||||
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
line := s.Text()
|
line := s.Text()
|
||||||
|
// GitHub's runner ignores blank lines
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
singleLineEnv := strings.Index(line, "=")
|
singleLineEnv := strings.Index(line, "=")
|
||||||
multiLineEnv := strings.Index(line, "<<")
|
multiLineEnv := strings.Index(line, "<<")
|
||||||
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/text/encoding"
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
||||||
@@ -64,6 +66,63 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "reading env file")
|
assert.Contains(t, err.Error(), "reading env file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression test: a blank line used to fail the job at "Complete Job", after
|
||||||
|
// every step had already been recorded as successful.
|
||||||
|
func TestParseEnvFileBlankLines(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
|
assert.Equal(t, "qux", env["BAZ"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// blank lines inside a heredoc value are content, not separators
|
||||||
|
func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("FOO=bar\n")...)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows host mode: PowerShell 5.1 redirection writes UTF-16, which used to be
|
||||||
|
// unrecognisable as KEY=VALUE, so the writes were silently ignored.
|
||||||
|
func TestParseEnvFileUTF16(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
encoder *encoding.Encoder
|
||||||
|
}{
|
||||||
|
{"little endian", unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder()},
|
||||||
|
{"big endian", unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewEncoder()},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e, envPath := newTestHostEnv(t)
|
||||||
|
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||||
|
|
||||||
|
env := map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
|
assert.Equal(t, "line1", env["MULTI"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseEnvFileMissingDelimiter(t *testing.T) {
|
func TestParseEnvFileMissingDelimiter(t *testing.T) {
|
||||||
e, envPath := newTestHostEnv(t)
|
e, envPath := newTestHostEnv(t)
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
||||||
|
|||||||
@@ -274,8 +274,17 @@ func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pr
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jobStatus returns the current job status, treating a nil Job context as an
|
||||||
|
// empty status so status-check functions never panic on a nil dereference.
|
||||||
|
func (impl *interperterImpl) jobStatus() string {
|
||||||
|
if impl.env.Job == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return impl.env.Job.Status
|
||||||
|
}
|
||||||
|
|
||||||
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
return impl.env.Job.Status == "success", nil
|
return impl.jobStatus() == "success", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
@@ -292,9 +301,9 @@ func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
return impl.env.Job.Status == "failure", nil
|
return impl.jobStatus() == "failure", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
return impl.env.Job.Status == "cancelled", nil
|
return impl.jobStatus() == "cancelled", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,3 +254,27 @@ func TestFunctionFormat(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatusFunctionsNilJob(t *testing.T) {
|
||||||
|
// A nil Job context must not panic: the status-check functions should treat
|
||||||
|
// it as an empty status and return false rather than dereferencing nil.
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
context string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"cancelled()", "job", "cancelled-nil-job"},
|
||||||
|
{"success()", "step", "step-success-nil-job"},
|
||||||
|
{"failure()", "step", "step-failure-nil-job"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, false, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
stepContainer.Start(true),
|
stepContainer.Start(true),
|
||||||
).Finally(
|
).Finally(
|
||||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||||
).Finally(stepContainer.Close())(ctx)
|
).Finally(stepContainer.Close())(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type closerMock struct {
|
type closerMock struct {
|
||||||
@@ -150,6 +151,44 @@ runs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
||||||
|
func TestExecAsDockerAutoRemove(t *testing.T) {
|
||||||
|
orig := ContainerNewContainer
|
||||||
|
defer func() { ContainerNewContainer = orig }()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
autoRemove bool
|
||||||
|
removes int
|
||||||
|
}{
|
||||||
|
{false, 2}, // stale + post-run
|
||||||
|
{true, 1}, // post-run skipped
|
||||||
|
} {
|
||||||
|
cm := &containerMock{}
|
||||||
|
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
|
||||||
|
|
||||||
|
step := &stepActionRemote{
|
||||||
|
Step: &model.Step{ID: "1", Uses: "org/action@v1"},
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{AutoRemove: tc.autoRemove},
|
||||||
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||||
|
JobContainer: cm,
|
||||||
|
},
|
||||||
|
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "docker://node:14"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
removes := 0
|
||||||
|
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
|
||||||
|
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||||
|
|
||||||
|
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false))
|
||||||
|
cm.AssertExpectations(t)
|
||||||
|
assert.Equal(t, tc.removes, removes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestActionRunner(t *testing.T) {
|
func TestActionRunner(t *testing.T) {
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -283,3 +283,30 @@ func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
|||||||
require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline")
|
require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline")
|
||||||
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved")
|
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reportStepError must treat a context.Canceled (e.g. a teardown-cancelled read) as an
|
||||||
|
// interruption, never a job failure.
|
||||||
|
func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
|
||||||
|
rc := &RunContext{}
|
||||||
|
|
||||||
|
// stray read cancellation while the job context is live: ignored, not a failure
|
||||||
|
live := common.WithJobErrorContainer(context.Background())
|
||||||
|
reportStepError(live, rc, context.Canceled)
|
||||||
|
require.NoError(t, common.JobError(live))
|
||||||
|
assert.False(t, rc.jobFailed)
|
||||||
|
assert.False(t, rc.jobCancelled)
|
||||||
|
|
||||||
|
// genuine job cancellation: recorded as cancelled, still not a failure
|
||||||
|
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||||
|
cancel()
|
||||||
|
reportStepError(cancelled, rc, context.Canceled)
|
||||||
|
require.NoError(t, common.JobError(cancelled))
|
||||||
|
assert.False(t, rc.jobFailed)
|
||||||
|
assert.True(t, rc.jobCancelled)
|
||||||
|
|
||||||
|
// a real error still fails the job
|
||||||
|
failed := common.WithJobErrorContainer(context.Background())
|
||||||
|
reportStepError(failed, rc, assert.AnError)
|
||||||
|
require.ErrorIs(t, common.JobError(failed), assert.AnError)
|
||||||
|
assert.True(t, rc.jobFailed)
|
||||||
|
}
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
|
|||||||
logger.Debugf("evaluating expression '%s'", in)
|
logger.Debugf("evaluating expression '%s'", in)
|
||||||
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
||||||
|
|
||||||
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%t", evaluated), "::add-mask::***)")
|
// evaluated is an any: %t renders everything but a bool as "%!t(string=...)"
|
||||||
|
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)")
|
||||||
logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
|
logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
|
||||||
|
|
||||||
return evaluated, err
|
return evaluated, err
|
||||||
@@ -497,11 +498,7 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
|
|||||||
if value == nil {
|
if value == nil {
|
||||||
value = v.Default
|
value = v.Default
|
||||||
}
|
}
|
||||||
if v.Type == "boolean" {
|
inputs[k] = coerceInputValue(value, v.Type)
|
||||||
inputs[k] = value == "true"
|
|
||||||
} else {
|
|
||||||
inputs[k] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,17 +511,26 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
|
|||||||
if value == nil {
|
if value == nil {
|
||||||
value = v.Default
|
value = v.Default
|
||||||
}
|
}
|
||||||
if v.Type == "boolean" {
|
inputs[k] = coerceInputValue(value, v.Type)
|
||||||
inputs[k] = value == "true"
|
|
||||||
} else {
|
|
||||||
inputs[k] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inputs
|
return inputs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// coerceInputValue converts an input value to the type declared by the workflow.
|
||||||
|
// The event payload carries natively typed JSON values on newer Gitea versions,
|
||||||
|
// while defaults and older servers provide strings.
|
||||||
|
func coerceInputValue(value any, inputType string) any {
|
||||||
|
if inputType != "boolean" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if b, ok := value.(bool); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return value == "true"
|
||||||
|
}
|
||||||
|
|
||||||
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||||
@@ -548,7 +554,7 @@ func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(*inputs)[name] = value
|
(*inputs)[name] = coerceInputValue(value, input.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,3 +323,82 @@ func TestRewriteSubExpressionForceFormat(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
|
workflows := map[string]string{
|
||||||
|
"workflow_call": `
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
flag:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
default: gitea
|
||||||
|
`,
|
||||||
|
"workflow_dispatch": `
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
flag:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
default: gitea
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := []struct {
|
||||||
|
name string
|
||||||
|
event map[string]any
|
||||||
|
flag any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Gitea >= 1.27 resolves the inputs server-side and sends native JSON types
|
||||||
|
name: "native bool true",
|
||||||
|
event: map[string]any{"inputs": map[string]any{"flag": true}},
|
||||||
|
flag: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "native bool false",
|
||||||
|
event: map[string]any{"inputs": map[string]any{"flag": false}},
|
||||||
|
flag: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string true",
|
||||||
|
event: map[string]any{"inputs": map[string]any{"flag": "true"}},
|
||||||
|
flag: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string false",
|
||||||
|
event: map[string]any{"inputs": map[string]any{"flag": "false"}},
|
||||||
|
flag: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default is used when the event carries no inputs",
|
||||||
|
event: map[string]any{},
|
||||||
|
flag: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for eventName, workflow := range workflows {
|
||||||
|
for _, table := range tables {
|
||||||
|
t.Run(eventName+"/"+table.name, func(t *testing.T) {
|
||||||
|
wf, err := model.ReadWorkflow(strings.NewReader(workflow))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{Workdir: "."},
|
||||||
|
Run: &model.Run{JobID: "job1", Workflow: wf},
|
||||||
|
}
|
||||||
|
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
||||||
|
|
||||||
|
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
|
||||||
|
assert.Equal(t, table.flag, inputs["flag"])
|
||||||
|
assert.Equal(t, "gitea", inputs["name"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -56,17 +57,81 @@ type jobInfo interface {
|
|||||||
result(result string)
|
result(result string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportStepError emits the GitHub Actions ##[error] annotation and records
|
// reportStepError records a step error so the job is reported failed — except a
|
||||||
// the error against the job so the job is reported as failed.
|
// cancellation, which is an interruption, not a failure.
|
||||||
func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
// Defer to the job context: a genuine cancel reports cancelled, a stray teardown
|
||||||
|
// cancellation on a live ctx is ignored — never a step FAILURE.
|
||||||
|
rc.markInterrupted(ctx.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
common.Logger(ctx).Errorf("##[error]%v", err)
|
common.Logger(ctx).Errorf("##[error]%v", err)
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
rc.markFailed()
|
rc.markFailed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actionPreparer is implemented by steps that download an action before they run, so the job
|
||||||
|
// executor can fetch all of them up front.
|
||||||
|
type actionPreparer interface {
|
||||||
|
prepareActionExecutor() common.Executor
|
||||||
|
actionDownloadInfo() (reference, sha string, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPrepareActions downloads every action the job uses before its first step runs and reports
|
||||||
|
// them as actions/runner's "Prepare all required actions" section does. The steps still call
|
||||||
|
// prepareActionExecutor themselves; it is a no-op once the action is resolved here.
|
||||||
|
func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if len(preparers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
rawLogger.Infof("Prepare all required actions")
|
||||||
|
|
||||||
|
for _, preparer := range preparers {
|
||||||
|
if err := preparer.prepareActionExecutor()(ctx); err != nil {
|
||||||
|
// No step has run yet, so the failure belongs to the job.
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reference, sha, ok := preparer.actionDownloadInfo()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sha == "" {
|
||||||
|
rawLogger.Infof("Download action repository '%s'", reference)
|
||||||
|
} else {
|
||||||
|
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step.
|
||||||
|
func printCompleteJobName(rc *RunContext) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
// Name holds a matrix combination; JobName is the shared name GitHub reports.
|
||||||
|
name := rc.JobName
|
||||||
|
if name == "" {
|
||||||
|
name = rc.Name
|
||||||
|
}
|
||||||
|
if name == "" && rc.Run != nil {
|
||||||
|
name = rc.Run.JobID
|
||||||
|
}
|
||||||
|
common.Logger(ctx).WithField(rawOutputField, true).Infof("Complete job name: %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
||||||
steps := make([]common.Executor, 0)
|
steps := make([]common.Executor, 0)
|
||||||
preSteps := make([]common.Executor, 0)
|
preSteps := make([]common.Executor, 0)
|
||||||
|
// Collected separately: every action is downloaded before the first pre step runs.
|
||||||
|
stepPreSteps := make([]common.Executor, 0)
|
||||||
|
preparers := make([]actionPreparer, 0)
|
||||||
var postExecutor common.Executor
|
var postExecutor common.Executor
|
||||||
|
|
||||||
steps = append(steps, func(ctx context.Context) error {
|
steps = append(steps, func(ctx context.Context) error {
|
||||||
@@ -113,9 +178,13 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
return common.NewErrorExecutor(err)
|
return common.NewErrorExecutor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if preparer, ok := step.(actionPreparer); ok {
|
||||||
|
preparers = append(preparers, preparer)
|
||||||
|
}
|
||||||
|
|
||||||
stepIdx := stepModel.Number
|
stepIdx := stepModel.Number
|
||||||
preExec := step.pre()
|
preExec := step.pre()
|
||||||
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
stepPreSteps = append(stepPreSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||||
rc.CurrentStepIndex = stepIdx
|
rc.CurrentStepIndex = stepIdx
|
||||||
preErr := preExec(ctx)
|
preErr := preExec(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
@@ -157,6 +226,11 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The setup section of the job log: download the actions, run the pre steps, then name the job.
|
||||||
|
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
||||||
|
preSteps = append(preSteps, stepPreSteps...)
|
||||||
|
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
@@ -111,6 +112,182 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er
|
|||||||
return args.Get(0).(step), args.Error(1)
|
return args.Get(0).(step), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actionPreparerMock stands in for a step whose action is downloaded before the job's first step.
|
||||||
|
type actionPreparerMock struct {
|
||||||
|
reference string
|
||||||
|
sha string
|
||||||
|
ok bool
|
||||||
|
err error
|
||||||
|
prepared int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
apm.prepared++
|
||||||
|
return apm.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (apm *actionPreparerMock) actionDownloadInfo() (string, string, bool) {
|
||||||
|
return apm.reference, apm.sha, apm.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintPrepareActionsGolden(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
logger := log.New()
|
||||||
|
logger.SetOutput(buf)
|
||||||
|
logger.SetLevel(log.InfoLevel)
|
||||||
|
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||||
|
|
||||||
|
preparers := []actionPreparer{
|
||||||
|
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", ok: true},
|
||||||
|
// A resolved commit is best effort; the ref alone is reported when it is unknown.
|
||||||
|
&actionPreparerMock{reference: "actions/setup-go@v6", ok: true},
|
||||||
|
// A step that downloads nothing, such as the checkout of the workflow's own repository.
|
||||||
|
&actionPreparerMock{ok: false},
|
||||||
|
}
|
||||||
|
require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx))
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"[j1] | Prepare all required actions",
|
||||||
|
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)",
|
||||||
|
"[j1] | Download action repository 'actions/setup-go@v6'",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
assert.Equal(t, want, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
logger := log.New()
|
||||||
|
logger.SetOutput(buf)
|
||||||
|
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||||
|
|
||||||
|
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
|
||||||
|
|
||||||
|
assert.Empty(t, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
||||||
|
logger, _ := logrustest.NewNullLogger()
|
||||||
|
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("job", "j1")))
|
||||||
|
|
||||||
|
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
|
||||||
|
rc := &RunContext{}
|
||||||
|
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", ok: true}
|
||||||
|
|
||||||
|
err := printPrepareActions(rc, []actionPreparer{
|
||||||
|
&actionPreparerMock{err: downloadErr},
|
||||||
|
remaining,
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, downloadErr)
|
||||||
|
// No step has run yet, so the failure has to be recorded against the job itself.
|
||||||
|
assert.Equal(t, downloadErr, common.JobError(ctx))
|
||||||
|
assert.True(t, rc.jobFailed)
|
||||||
|
assert.Zero(t, remaining.prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintCompleteJobName(t *testing.T) {
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
rc *RunContext
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"job name": {rc: &RunContext{JobName: "lint", Name: "lint-1"}, want: "lint"},
|
||||||
|
"falls back to name": {rc: &RunContext{Name: "lint-1"}, want: "lint-1"},
|
||||||
|
"falls back to jobID": {rc: &RunContext{Run: &model.Run{JobID: "lint"}}, want: "lint"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
logger := log.New()
|
||||||
|
logger.SetOutput(buf)
|
||||||
|
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||||
|
|
||||||
|
require.NoError(t, printCompleteJobName(tt.rc)(ctx))
|
||||||
|
|
||||||
|
assert.Equal(t, "[j1] | Complete job name: "+tt.want+"\n", buf.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionStepMock is a step whose action has to be downloaded before it can run.
|
||||||
|
type actionStepMock struct {
|
||||||
|
*stepMock
|
||||||
|
*actionPreparerMock
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep pins the shape of the setup section:
|
||||||
|
// every action is downloaded before any step runs, and the job name closes the section. A pre
|
||||||
|
// step that downloaded its own action would leave the log interleaved with the downloads.
|
||||||
|
func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
||||||
|
ctx := common.WithJobErrorContainer(context.Background())
|
||||||
|
jim := &jobInfoMock{}
|
||||||
|
sfm := &stepFactoryMock{}
|
||||||
|
rc := &RunContext{
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "test",
|
||||||
|
Workflow: &model.Workflow{
|
||||||
|
Jobs: map[string]*model.Job{"test": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Config: &Config{},
|
||||||
|
}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
|
steps := []*model.Step{{ID: "1"}, {ID: "2"}}
|
||||||
|
executorOrder := make([]string, 0)
|
||||||
|
|
||||||
|
jim.On("steps").Return(steps)
|
||||||
|
jim.On("matrix").Return(map[string]any{})
|
||||||
|
jim.On("startContainer").Return(func(context.Context) error { return nil })
|
||||||
|
jim.On("stopContainer").Return(func(context.Context) error { return nil })
|
||||||
|
jim.On("closeContainer").Return(func(context.Context) error { return nil })
|
||||||
|
jim.On("interpolateOutputs").Return(func(context.Context) error { return nil })
|
||||||
|
jim.On("result", "success")
|
||||||
|
|
||||||
|
for _, stepModel := range steps {
|
||||||
|
sm := &stepMock{}
|
||||||
|
apm := &actionPreparerMock{reference: "actions/checkout@v" + stepModel.ID, ok: true}
|
||||||
|
sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil)
|
||||||
|
|
||||||
|
sm.On("pre").Return(func(context.Context) error {
|
||||||
|
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sm.On("main").Return(func(context.Context) error {
|
||||||
|
executorOrder = append(executorOrder, "step"+stepModel.ID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sm.On("post").Return(func(context.Context) error { return nil })
|
||||||
|
|
||||||
|
defer sm.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.WithField("job", "test")))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder)
|
||||||
|
|
||||||
|
setup := make([]string, 0)
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") ||
|
||||||
|
strings.HasPrefix(entry.Message, "Complete job name") {
|
||||||
|
setup = append(setup, entry.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"Prepare all required actions",
|
||||||
|
"Download action repository 'actions/checkout@v1'",
|
||||||
|
"Download action repository 'actions/checkout@v2'",
|
||||||
|
"Complete job name: test",
|
||||||
|
}, setup)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewJobExecutor(t *testing.T) {
|
func TestNewJobExecutor(t *testing.T) {
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import (
|
|||||||
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/opencontainers/selinux/go-selinux"
|
"github.com/opencontainers/selinux/go-selinux"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunContext contains info about current job
|
// RunContext contains info about current job
|
||||||
@@ -139,7 +138,9 @@ func (rc *RunContext) GetEnv() map[string]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !rc.Config.DisableActEnv {
|
||||||
rc.Env["ACT"] = "true"
|
rc.Env["ACT"] = "true"
|
||||||
|
}
|
||||||
|
|
||||||
if !rc.Config.NoSkipCheckout {
|
if !rc.Config.NoSkipCheckout {
|
||||||
rc.Env["ACT_SKIP_CHECKOUT"] = "true"
|
rc.Env["ACT_SKIP_CHECKOUT"] = "true"
|
||||||
@@ -338,6 +339,9 @@ func printStartJobContainerGroup(ctx context.Context, image, name, network strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newContainer is a variable so tests can substitute a container that needs no Docker daemon.
|
||||||
|
var newContainer = container.NewContainer
|
||||||
|
|
||||||
func (rc *RunContext) startJobContainer() common.Executor {
|
func (rc *RunContext) startJobContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
@@ -380,6 +384,13 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
|
|
||||||
// add service containers
|
// add service containers
|
||||||
for serviceID, spec := range rc.Run.Job().Services {
|
for serviceID, spec := range rc.Run.Job().Services {
|
||||||
|
// GitHub compatibility: skip services whose image evaluates to an
|
||||||
|
// empty string, enabling conditional services via expressions
|
||||||
|
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||||
|
if serviceImage == "" {
|
||||||
|
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// interpolate env
|
// interpolate env
|
||||||
interpolatedEnvs := make(map[string]string, len(spec.Env))
|
interpolatedEnvs := make(map[string]string, len(spec.Env))
|
||||||
for k, v := range spec.Env {
|
for k, v := range spec.Env {
|
||||||
@@ -394,7 +405,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
for _, v := range spec.Cmd {
|
for _, v := range spec.Cmd {
|
||||||
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v))
|
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v))
|
||||||
}
|
}
|
||||||
username, password, err = rc.handleServiceCredentials(ctx, spec.Credentials)
|
// keep these local: reusing username/password would overwrite the
|
||||||
|
// credentials the job container is pulled with further down
|
||||||
|
serviceUsername, servicePassword, err := rc.handleServiceCredentials(ctx, spec.Credentials)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||||
}
|
}
|
||||||
@@ -415,12 +428,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
||||||
c := container.NewContainer(&container.NewContainerInput{
|
c := newContainer(&container.NewContainerInput{
|
||||||
Name: serviceContainerName,
|
Name: serviceContainerName,
|
||||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||||
Image: rc.ExprEval.Interpolate(ctx, spec.Image),
|
Image: serviceImage,
|
||||||
Username: username,
|
Username: serviceUsername,
|
||||||
Password: password,
|
Password: servicePassword,
|
||||||
Cmd: interpolatedCmd,
|
Cmd: interpolatedCmd,
|
||||||
Env: envs,
|
Env: envs,
|
||||||
Mounts: serviceMounts,
|
Mounts: serviceMounts,
|
||||||
@@ -441,12 +454,42 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
rc.cleanUpJobContainer = rc.cleanupJobContainer(logger, networkName, createAndDeleteNetwork)
|
rc.cleanUpJobContainer = func(ctx context.Context) error {
|
||||||
|
reuseJobContainer := func(ctx context.Context) bool {
|
||||||
|
return rc.Config.ReuseContainers
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.JobContainer != nil {
|
||||||
|
return rc.JobContainer.Remove().IfNot(reuseJobContainer).
|
||||||
|
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false)).IfNot(reuseJobContainer).
|
||||||
|
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName()+"-env", false)).IfNot(reuseJobContainer).
|
||||||
|
Then(func(ctx context.Context) error {
|
||||||
|
if len(rc.ServiceContainers) > 0 {
|
||||||
|
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
|
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||||
|
logger.Errorf("Error while cleaning services: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if createAndDeleteNetwork {
|
||||||
|
// clean network if it has been created by act
|
||||||
|
// if using service containers
|
||||||
|
// it means that the network to which containers are connecting is created by `runner`,
|
||||||
|
// so, we should remove the network at last.
|
||||||
|
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||||
|
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||||
|
logger.Errorf("Error while cleaning network: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||||
jobContainerNetwork := networkName
|
jobContainerNetwork := networkName
|
||||||
|
|
||||||
rc.JobContainer = container.NewContainer(&container.NewContainerInput{
|
rc.JobContainer = newContainer(&container.NewContainerInput{
|
||||||
Cmd: nil,
|
Cmd: nil,
|
||||||
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||||
@@ -496,40 +539,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) cleanupJobContainer(logger logrus.FieldLogger, networkName string, createAndDeleteNetwork bool) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
reuseJobContainer := func(ctx context.Context) bool {
|
|
||||||
return rc.Config.ReuseContainers
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := common.NewPipelineExecutor()
|
|
||||||
if rc.JobContainer != nil {
|
|
||||||
cleanup = rc.JobContainer.Remove().IfNot(reuseJobContainer).
|
|
||||||
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false)).IfNot(reuseJobContainer).
|
|
||||||
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName()+"-env", false)).IfNot(reuseJobContainer)
|
|
||||||
}
|
|
||||||
return cleanup.Then(func(ctx context.Context) error {
|
|
||||||
if len(rc.ServiceContainers) > 0 {
|
|
||||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
|
||||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
|
||||||
logger.Errorf("Error while cleaning services: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if createAndDeleteNetwork {
|
|
||||||
// clean network if it has been created by act
|
|
||||||
// if using service containers
|
|
||||||
// it means that the network to which containers are connecting is created by `runner`,
|
|
||||||
// so, we should remove the network at last.
|
|
||||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
|
||||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
|
||||||
logger.Errorf("Error while cleaning network: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor { //nolint:unparam // pre-existing issue from nektos/act
|
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
|
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
|
||||||
@@ -788,7 +797,13 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
res, err := rc.isEnabled(ctx)
|
res, err := rc.isEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure") // For Gitea
|
// Record the failure so a job whose if-expression fails to evaluate
|
||||||
|
// gets a result (and therefore a stop time) instead of being left
|
||||||
|
// unfinished. rc.caller is only set for reusable workflows.
|
||||||
|
rc.result("failure")
|
||||||
|
if rc.caller != nil { // For Gitea
|
||||||
|
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure")
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if res {
|
if res {
|
||||||
|
|||||||
@@ -203,6 +203,93 @@ jobs:
|
|||||||
assert.Empty(t, password)
|
assert.Empty(t, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fakeContainer turns every container operation into a no-op, so startJobContainer
|
||||||
|
// runs without a Docker daemon. The embedded interface is nil, so any method the
|
||||||
|
// test does not exercise panics rather than silently doing the wrong thing.
|
||||||
|
type fakeContainer struct {
|
||||||
|
container.ExecutionsEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeContainer) Pull(bool) common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
func (fakeContainer) Start(bool) common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
||||||
|
func (fakeContainer) Create([]string, []string) common.Executor {
|
||||||
|
return func(context.Context) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeContainer) Copy(string, ...*container.FileEntry) common.Executor {
|
||||||
|
return func(context.Context) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression test: a service without a `credentials:` block resolves to empty
|
||||||
|
// credentials, which used to overwrite the job container's own credentials.
|
||||||
|
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
name: test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: registry.example/private:latest
|
||||||
|
credentials:
|
||||||
|
username: job-user
|
||||||
|
password: job-password
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
db:
|
||||||
|
image: postgres:latest
|
||||||
|
credentials:
|
||||||
|
username: db-user
|
||||||
|
password: db-password
|
||||||
|
steps: []
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var inputs []*container.NewContainerInput
|
||||||
|
origNewContainer := newContainer
|
||||||
|
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
|
inputs = append(inputs, input)
|
||||||
|
return fakeContainer{}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newContainer = origNewContainer })
|
||||||
|
|
||||||
|
rc := &RunContext{
|
||||||
|
Name: "test",
|
||||||
|
Config: &Config{
|
||||||
|
Workdir: "/tmp",
|
||||||
|
// no daemon: an explicit network mode creates no network, and
|
||||||
|
// reusing containers short-circuits the volume cleanup executors
|
||||||
|
ContainerNetworkMode: "host",
|
||||||
|
ReuseContainers: true,
|
||||||
|
Env: map[string]string{},
|
||||||
|
Secrets: map[string]string{},
|
||||||
|
},
|
||||||
|
Env: map[string]string{},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "job",
|
||||||
|
Workflow: workflow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
|
|
||||||
|
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||||
|
|
||||||
|
credentials := map[string][2]string{}
|
||||||
|
for _, in := range inputs {
|
||||||
|
credentials[in.Image] = [2]string{in.Username, in.Password}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the job container keeps its own credentials, whichever services exist
|
||||||
|
require.Equal(t, [2]string{"job-user", "job-password"}, credentials["registry.example/private:latest"])
|
||||||
|
// each service keeps its own, and a service without credentials gets none
|
||||||
|
require.Equal(t, [2]string{"db-user", "db-password"}, credentials["postgres:latest"])
|
||||||
|
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||||
rctemplate := &RunContext{
|
rctemplate := &RunContext{
|
||||||
Name: "TestRCName",
|
Name: "TestRCName",
|
||||||
@@ -364,21 +451,6 @@ func TestRunContextValidVolumes(t *testing.T) {
|
|||||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobContainerCleansServicesWithoutJobContainer(t *testing.T) {
|
|
||||||
service := &containerMock{}
|
|
||||||
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
|
|
||||||
service.On("Close").Return(func(context.Context) error { return nil }).Once()
|
|
||||||
|
|
||||||
rc := &RunContext{
|
|
||||||
Config: &Config{},
|
|
||||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rc.cleanupJobContainer(log.StandardLogger(), "external-network", false)(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
service.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
||||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
||||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type Config struct {
|
|||||||
ArtifactServerAddr string // the address the artifact server binds to
|
ArtifactServerAddr string // the address the artifact server binds to
|
||||||
ArtifactServerPort string // the port the artifact server binds to
|
ArtifactServerPort string // the port the artifact server binds to
|
||||||
NoSkipCheckout bool // do not skip actions/checkout
|
NoSkipCheckout bool // do not skip actions/checkout
|
||||||
|
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
|
||||||
RemoteName string // remote name in local git repo config
|
RemoteName string // remote name in local git repo config
|
||||||
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
|
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
|
||||||
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// services
|
// services
|
||||||
{workdir, "services", "push", "", platforms, secrets},
|
{workdir, "services", "push", "", platforms, secrets},
|
||||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||||
|
{workdir, "services-empty-image", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// local remote action overrides
|
// local remote action overrides
|
||||||
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets},
|
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets},
|
||||||
|
|||||||
@@ -107,7 +107,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if strings.Contains(stepString, "::add-mask::") {
|
if strings.Contains(stepString, "::add-mask::") {
|
||||||
stepString = "add-mask command"
|
stepString = "add-mask command"
|
||||||
}
|
}
|
||||||
|
if stage == stepStageMain {
|
||||||
|
// Main steps print their own raw "Run <title>" header, so this line is redundant and
|
||||||
|
// only leaks into the "Set up job" section for the first step; keep it as a debug trace.
|
||||||
|
logger.Debugf("Run %s %s", stage, stepString)
|
||||||
|
} else {
|
||||||
logger.Infof("Run %s %s", stage, stepString)
|
logger.Infof("Run %s %s", stage, stepString)
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare and clean Runner File Commands
|
// Prepare and clean Runner File Commands
|
||||||
actPath := rc.JobContainer.GetActPath()
|
actPath := rc.JobContainer.GetActPath()
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
Token: token,
|
Token: token,
|
||||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||||
|
// printPrepareActions reports the download with its resolved commit.
|
||||||
|
Quiet: true,
|
||||||
|
|
||||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||||
})
|
})
|
||||||
@@ -146,6 +148,13 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best effort: the download report falls back to the ref alone when the commit is unknown.
|
||||||
|
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
|
||||||
|
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
|
||||||
|
} else {
|
||||||
|
sar.resolvedSha = sha
|
||||||
|
}
|
||||||
|
|
||||||
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
|
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
return func(filename string) (io.Reader, io.Closer, error) {
|
return func(filename string) (io.Reader, io.Closer, error) {
|
||||||
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
||||||
@@ -165,6 +174,15 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actionDownloadInfo reports the action this step downloaded and the commit it resolved to. ok is
|
||||||
|
// false when nothing was fetched, as for the local checkout of the workflow's own repository.
|
||||||
|
func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string, ok bool) {
|
||||||
|
if sar.remoteAction == nil || sar.action == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return sar.remoteAction.Reference(), sar.resolvedSha, true
|
||||||
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) pre() common.Executor {
|
func (sar *stepActionRemote) pre() common.Executor {
|
||||||
sar.env = map[string]string{}
|
sar.env = map[string]string{}
|
||||||
|
|
||||||
@@ -313,6 +331,16 @@ func (ra *remoteAction) CloneURL(u string) string {
|
|||||||
return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
|
return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference renders the action as {org}/{repo}[/path]@{ref}, omitting the download source, which
|
||||||
|
// can be interpolated from a secret.
|
||||||
|
func (ra *remoteAction) Reference() string {
|
||||||
|
repo := fmt.Sprintf("%s/%s", ra.Org, ra.Repo)
|
||||||
|
if ra.Path != "" {
|
||||||
|
repo = fmt.Sprintf("%s/%s", repo, ra.Path)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s@%s", repo, ra.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
func (ra *remoteAction) IsCheckout() bool {
|
func (ra *remoteAction) IsCheckout() bool {
|
||||||
if ra.Org == "actions" && ra.Repo == "checkout" {
|
if ra.Org == "actions" && ra.Repo == "checkout" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -818,6 +821,97 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_remoteActionReference(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
uses string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{uses: "actions/checkout@v7", want: "actions/checkout@v7"},
|
||||||
|
{uses: "actions/aws/ec2@main", want: "actions/aws/ec2@main"},
|
||||||
|
// The download source can be interpolated from a secret and must stay out of the log.
|
||||||
|
{uses: "https://gitea.example.com/actions/checkout@v7", want: "actions/checkout@v7"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.uses, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStepActionRemotePreResolvesDownloadedCommit runs the real download path against a local
|
||||||
|
// git repository standing in for the actions instance, so the reported commit is the one the
|
||||||
|
// clone actually checked out.
|
||||||
|
func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
|
||||||
|
instance := t.TempDir()
|
||||||
|
actionDir := filepath.Join(instance, "actions", "setup-go")
|
||||||
|
require.NoError(t, os.MkdirAll(actionDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "action.yml"),
|
||||||
|
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
|
||||||
|
|
||||||
|
// Supply an identity on the commit so the test does not depend on a
|
||||||
|
// git identity being configured in the environment; a CI runner without
|
||||||
|
// user.name/user.email would otherwise fail "commit" with exit code 128.
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"init", "--initial-branch=main", actionDir},
|
||||||
|
{"-C", actionDir, "add", "action.yml"},
|
||||||
|
{"-C", actionDir, "-c", "user.name=runner", "-c", "user.email=runner@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
|
||||||
|
} {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
require.NoError(t, cmd.Run(), "git %v", args)
|
||||||
|
}
|
||||||
|
out, err := exec.Command("git", "-C", actionDir, "rev-parse", "HEAD").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
wantSha := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
sar := &stepActionRemote{
|
||||||
|
Step: &model.Step{Uses: "actions/setup-go@main"},
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{
|
||||||
|
GitHubInstance: "https://gitea.example.com",
|
||||||
|
DefaultActionInstance: instance,
|
||||||
|
ActionCacheDir: t.TempDir(),
|
||||||
|
},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "1",
|
||||||
|
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readAction: readActionImpl,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, sar.prepareActionExecutor()(context.Background()))
|
||||||
|
|
||||||
|
reference, sha, ok := sar.actionDownloadInfo()
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "actions/setup-go@main", reference)
|
||||||
|
assert.Equal(t, wantSha, sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
||||||
|
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
||||||
|
sar := &stepActionRemote{
|
||||||
|
remoteAction: newRemoteAction("actions/checkout@v7"),
|
||||||
|
action: &model.Action{},
|
||||||
|
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||||
|
}
|
||||||
|
|
||||||
|
reference, sha, ok := sar.actionDownloadInfo()
|
||||||
|
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "actions/checkout@v7", reference)
|
||||||
|
assert.Equal(t, "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", sha)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
||||||
|
// The local checkout of the workflow's own repository resolves no action.
|
||||||
|
sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")}
|
||||||
|
|
||||||
|
_, _, ok := sar.actionDownloadInfo()
|
||||||
|
|
||||||
|
assert.False(t, ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func Test_safeFilename(t *testing.T) {
|
func Test_safeFilename(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
s string
|
s string
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
|||||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
stepContainer.Start(true),
|
stepContainer.Start(true),
|
||||||
).Finally(
|
).Finally(
|
||||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||||
).Finally(stepContainer.Close())(ctx)
|
).Finally(stepContainer.Close())(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStepDockerMain(t *testing.T) {
|
func TestStepDockerMain(t *testing.T) {
|
||||||
@@ -118,6 +119,43 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
||||||
|
func TestStepDockerAutoRemove(t *testing.T) {
|
||||||
|
orig := ContainerNewContainer
|
||||||
|
defer func() { ContainerNewContainer = orig }()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
autoRemove bool
|
||||||
|
removes int
|
||||||
|
}{
|
||||||
|
{false, 2}, // stale + post-run
|
||||||
|
{true, 1}, // post-run skipped
|
||||||
|
} {
|
||||||
|
cm := &containerMock{}
|
||||||
|
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
|
||||||
|
|
||||||
|
sd := &stepDocker{
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{AutoRemove: tc.autoRemove},
|
||||||
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||||
|
JobContainer: cm,
|
||||||
|
},
|
||||||
|
Step: &model.Step{ID: "1", Uses: "docker://node:14"},
|
||||||
|
}
|
||||||
|
|
||||||
|
removes := 0
|
||||||
|
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
|
||||||
|
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||||
|
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||||
|
|
||||||
|
require.NoError(t, sd.runUsesContainer()(context.Background()))
|
||||||
|
cm.AssertExpectations(t)
|
||||||
|
assert.Equal(t, tc.removes, removes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
10
act/runner/testdata/services-empty-image/push.yml
vendored
Normal file
10
act/runner/testdata/services-empty-image/push.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: services-empty-image
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: ${{ false && 'postgres:16' || '' }}
|
||||||
|
steps:
|
||||||
|
- run: echo "empty-image service was skipped"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"required": "required input",
|
"required": "required input",
|
||||||
"boolean": "true"
|
"boolean": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ NOTE: `dind-docker.yaml` uses the native sidecar pattern (init container with `r
|
|||||||
|
|
||||||
NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions
|
NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions
|
||||||
|
|
||||||
|
Each example persists **two** things, and it is worth knowing which is which:
|
||||||
|
|
||||||
|
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
|
||||||
|
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
|
||||||
|
|
||||||
Files in this directory:
|
Files in this directory:
|
||||||
|
|
||||||
- [`dind-docker.yaml`](dind-docker.yaml)
|
- [`dind-docker.yaml`](dind-docker.yaml)
|
||||||
@@ -13,3 +18,6 @@ Files in this directory:
|
|||||||
|
|
||||||
- [`rootless-docker.yaml`](rootless-docker.yaml)
|
- [`rootless-docker.yaml`](rootless-docker.yaml)
|
||||||
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
||||||
|
|
||||||
|
- [`statefulset-dind.yaml`](statefulset-dind.yaml)
|
||||||
|
StatefulSet variant of the dind example. Each replica gets a stable identity and its own persistent volume via `volumeClaimTemplates`, so the runner keeps its `.runner` registration across restarts and reschedules instead of trying to register again.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Holds the runner's working directory (/data): the .runner registration file
|
||||||
|
# and, optionally, the config file.
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
metadata:
|
metadata:
|
||||||
@@ -10,6 +12,21 @@ spec:
|
|||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
storageClassName: standard
|
storageClassName: standard
|
||||||
---
|
---
|
||||||
|
# Holds the Docker daemon's data root (/var/lib/docker), i.e. the images pulled
|
||||||
|
# for jobs. Without it, the image cache is lost whenever the pod is recreated
|
||||||
|
# and every job re-pulls its images. Size it for the images you expect to cache.
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: docker-vol
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
storageClassName: standard
|
||||||
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
data:
|
data:
|
||||||
# The registration token can be obtained from the web UI, API or command-line.
|
# The registration token can be obtained from the web UI, API or command-line.
|
||||||
@@ -45,6 +62,9 @@ spec:
|
|||||||
- name: runner-data
|
- name: runner-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: runner-vol
|
claimName: runner-vol
|
||||||
|
- name: docker-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: docker-vol
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: docker
|
- name: docker
|
||||||
image: docker:28.2.2-dind
|
image: docker:28.2.2-dind
|
||||||
@@ -53,6 +73,8 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: docker-socket
|
- name: docker-socket
|
||||||
mountPath: /var/run
|
mountPath: /var/run
|
||||||
|
- name: docker-data
|
||||||
|
mountPath: /var/lib/docker
|
||||||
startupProbe:
|
startupProbe:
|
||||||
exec:
|
exec:
|
||||||
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
|
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Holds the runner's working directory (/data): the .runner registration file
|
||||||
|
# and, optionally, the config file.
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
metadata:
|
metadata:
|
||||||
@@ -10,6 +12,21 @@ spec:
|
|||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
storageClassName: standard
|
storageClassName: standard
|
||||||
---
|
---
|
||||||
|
# Holds the rootless Docker daemon's data root, i.e. the images pulled for jobs.
|
||||||
|
# Without it, the image cache is lost whenever the pod is recreated and every job
|
||||||
|
# re-pulls its images. Size it for the images you expect to cache.
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: docker-vol
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
storageClassName: standard
|
||||||
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
data:
|
data:
|
||||||
# The registration token can be obtained from the web UI, API or command-line.
|
# The registration token can be obtained from the web UI, API or command-line.
|
||||||
@@ -43,7 +60,12 @@ spec:
|
|||||||
- name: runner-data
|
- name: runner-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: runner-vol
|
claimName: runner-vol
|
||||||
|
- name: docker-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: docker-vol
|
||||||
securityContext:
|
securityContext:
|
||||||
|
# The dind-rootless image runs as the `rootless` user (UID/GID 1000);
|
||||||
|
# fsGroup makes both volumes writable for it.
|
||||||
fsGroup: 1000
|
fsGroup: 1000
|
||||||
containers:
|
containers:
|
||||||
- name: runner
|
- name: runner
|
||||||
@@ -68,4 +90,7 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: runner-data
|
- name: runner-data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
# The rootless daemon keeps its images here, not under /data.
|
||||||
|
- name: docker-data
|
||||||
|
mountPath: /home/rootless/.local/share/docker
|
||||||
|
|
||||||
|
|||||||
96
examples/kubernetes/statefulset-dind.yaml
Normal file
96
examples/kubernetes/statefulset-dind.yaml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# StatefulSet variant of the dind example.
|
||||||
|
#
|
||||||
|
# Unlike the Deployment, a StatefulSet gives each replica a stable identity and,
|
||||||
|
# via volumeClaimTemplates, its own persistent volume. That means every runner
|
||||||
|
# pod keeps its own `.runner` registration file across restarts and reschedules,
|
||||||
|
# so it re-attaches to the server instead of trying to register again.
|
||||||
|
apiVersion: v1
|
||||||
|
data:
|
||||||
|
# The registration token can be obtained from the web UI, API or command-line.
|
||||||
|
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||||
|
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
|
||||||
|
token: << base64 encoded registration token >>
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: runner-secret
|
||||||
|
type: Opaque
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: runner
|
||||||
|
name: runner
|
||||||
|
spec:
|
||||||
|
serviceName: runner
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: runner
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: runner
|
||||||
|
spec:
|
||||||
|
restartPolicy: Always
|
||||||
|
volumes:
|
||||||
|
- name: docker-socket
|
||||||
|
emptyDir: {}
|
||||||
|
initContainers:
|
||||||
|
- name: docker
|
||||||
|
image: docker:28.2.2-dind
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: docker-socket
|
||||||
|
mountPath: /var/run
|
||||||
|
# Keeps the images pulled for jobs across restarts. Without this, the
|
||||||
|
# daemon's data root is ephemeral and every job re-pulls its images.
|
||||||
|
- name: docker-data
|
||||||
|
mountPath: /var/lib/docker
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]
|
||||||
|
restartPolicy: Always
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea/runner:nightly
|
||||||
|
env:
|
||||||
|
- name: GITEA_INSTANCE_URL
|
||||||
|
value: http://gitea-http.gitea.svc.cluster.local:3000
|
||||||
|
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: runner-secret
|
||||||
|
key: token
|
||||||
|
volumeMounts:
|
||||||
|
- name: runner-data
|
||||||
|
mountPath: /data
|
||||||
|
- name: docker-socket
|
||||||
|
mountPath: /var/run
|
||||||
|
volumeClaimTemplates:
|
||||||
|
# The runner's working directory: the .runner registration file and, optionally,
|
||||||
|
# the config file.
|
||||||
|
- metadata:
|
||||||
|
name: runner-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
storageClassName: standard
|
||||||
|
# The Docker daemon's data root: the images pulled for jobs. Size it for the
|
||||||
|
# images you expect to cache.
|
||||||
|
- metadata:
|
||||||
|
name: docker-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
storageClassName: standard
|
||||||
34
examples/systemd/README.md
Normal file
34
examples/systemd/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Running the runner as a systemd service
|
||||||
|
|
||||||
|
[`gitea-runner.service`](./gitea-runner.service) is an example unit for running
|
||||||
|
the runner as a background service on a systemd host.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install the `gitea-runner` binary (e.g. to `/usr/local/bin/gitea-runner`).
|
||||||
|
2. Create a dedicated user and working directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --home-dir /var/lib/gitea-runner --create-home gitea-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Generate a config and register the runner (as the service user), so the
|
||||||
|
`.runner` file ends up in the working directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u gitea-runner gitea-runner generate-config > /etc/gitea-runner/config.yaml
|
||||||
|
cd /var/lib/gitea-runner
|
||||||
|
sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Install and enable the unit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp gitea-runner.service /etc/systemd/system/gitea-runner.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now gitea-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust the binary path, config path, working directory and user to match your
|
||||||
|
installation. If jobs use the host's Docker daemon, uncomment the
|
||||||
|
`docker.service` dependencies in the unit.
|
||||||
30
examples/systemd/gitea-runner.service
Normal file
30
examples/systemd/gitea-runner.service
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Gitea Actions runner
|
||||||
|
Documentation=https://gitea.com/gitea/runner
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# Uncomment when jobs use the local Docker daemon:
|
||||||
|
# After=docker.service
|
||||||
|
# Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Adjust the binary path, config path and working directory to your setup.
|
||||||
|
# The working directory is where the .runner registration file is read from
|
||||||
|
# unless runner.file is set to an absolute path in the config.
|
||||||
|
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-runner/config.yaml
|
||||||
|
WorkingDirectory=/var/lib/gitea-runner
|
||||||
|
User=gitea-runner
|
||||||
|
Group=gitea-runner
|
||||||
|
|
||||||
|
# Restart automatically so the runner survives transient failures, e.g. the
|
||||||
|
# Gitea instance being temporarily unreachable at startup.
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# Allow running jobs to finish before the runner is stopped. Keep this in sync
|
||||||
|
# with runner.shutdown_timeout in the config.
|
||||||
|
TimeoutStopSec=3h
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
13
go.mod
13
go.mod
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v29.6.1+incompatible
|
github.com/docker/cli v29.6.2+incompatible
|
||||||
github.com/docker/go-connections v0.7.0
|
github.com/docker/go-connections v0.7.0
|
||||||
github.com/go-git/go-billy/v5 v5.9.0
|
github.com/go-git/go-billy/v5 v5.9.0
|
||||||
github.com/go-git/go-git/v5 v5.19.1
|
github.com/go-git/go-git/v5 v5.19.1
|
||||||
@@ -20,7 +20,7 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||||
github.com/mattn/go-isatty v0.0.22
|
github.com/mattn/go-isatty v0.0.23
|
||||||
github.com/moby/go-archive v0.2.0
|
github.com/moby/go-archive v0.2.0
|
||||||
github.com/moby/moby/api v1.55.0
|
github.com/moby/moby/api v1.55.0
|
||||||
github.com/moby/moby/client v0.5.0
|
github.com/moby/moby/client v0.5.0
|
||||||
@@ -29,6 +29,7 @@ require (
|
|||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/prometheus/client_model v0.6.2
|
||||||
github.com/rhysd/actionlint v1.7.12
|
github.com/rhysd/actionlint v1.7.12
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
@@ -37,8 +38,9 @@ require (
|
|||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||||
go.etcd.io/bbolt v1.5.0
|
go.etcd.io/bbolt v1.5.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/sys v0.46.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.44.0
|
golang.org/x/term v0.45.0
|
||||||
|
golang.org/x/text v0.40.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gotest.tools/v3 v3.5.2
|
gotest.tools/v3 v3.5.2
|
||||||
tags.cncf.io/container-device-interface v1.1.0
|
tags.cncf.io/container-device-interface v1.1.0
|
||||||
@@ -84,7 +86,6 @@ require (
|
|||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
@@ -106,7 +107,7 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.52.0 // indirect
|
||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
26
go.sum
26
go.sum
@@ -47,12 +47,10 @@ 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/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 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
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/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
|
|
||||||
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
|
||||||
github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8=
|
github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8=
|
||||||
github.com/docker/cli v29.6.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/docker/cli v29.6.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
|
github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw=
|
||||||
|
github.com/docker/cli v29.6.2+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 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
||||||
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
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=
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
@@ -123,6 +121,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||||
@@ -131,12 +131,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
|
|||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
|
||||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
|
||||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
|
||||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
|
||||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
@@ -215,8 +211,6 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
|
|||||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
|
||||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||||
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
|
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
|
||||||
@@ -252,6 +246,8 @@ 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/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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -260,14 +256,16 @@ 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.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.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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
|||||||
31
internal/app/cmd/bug_report.go
Normal file
31
internal/app/cmd/bug_report.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadBugReportCmd prints environment details that are useful when opening a
|
||||||
|
// bug report, so users can paste them straight into an issue.
|
||||||
|
func loadBugReportCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "bug-report",
|
||||||
|
Short: "Print information useful when filing a bug report",
|
||||||
|
Args: cobra.MaximumNArgs(0),
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
w := cmd.OutOrStdout()
|
||||||
|
fmt.Fprintf(w, "Runner version: %s\n", ver.Version())
|
||||||
|
fmt.Fprintf(w, "Go version: %s\n", runtime.Version())
|
||||||
|
fmt.Fprintf(w, "OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||||
|
fmt.Fprintf(w, "NumCPU: %d\n", runtime.NumCPU())
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ func Execute(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
registerCmd.Flags().BoolVar(®Args.NoInteractive, "no-interactive", false, "Disable interactive mode")
|
registerCmd.Flags().BoolVar(®Args.NoInteractive, "no-interactive", false, "Disable interactive mode")
|
||||||
registerCmd.Flags().StringVar(®Args.InstanceAddr, "instance", "", "Gitea instance address")
|
registerCmd.Flags().StringVar(®Args.InstanceAddr, "instance", "", "Gitea instance address")
|
||||||
registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token")
|
registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token (or set the GITEA_RUNNER_REGISTRATION_TOKEN envvar)")
|
||||||
|
registerCmd.Flags().StringVar(®Args.TokenFile, "token-file", "", "Path to a file containing the runner token")
|
||||||
registerCmd.Flags().StringVar(®Args.RunnerName, "name", "", "Runner name")
|
registerCmd.Flags().StringVar(®Args.RunnerName, "name", "", "Runner name")
|
||||||
registerCmd.Flags().StringVar(®Args.Labels, "labels", "", "Runner tags, comma separated")
|
registerCmd.Flags().StringVar(®Args.Labels, "labels", "", "Runner tags, comma separated")
|
||||||
registerCmd.Flags().BoolVar(®Args.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
|
registerCmd.Flags().BoolVar(®Args.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
|
||||||
@@ -50,11 +51,15 @@ func Execute(ctx context.Context) {
|
|||||||
RunE: runDaemon(ctx, &daemArgs, &configFile),
|
RunE: runDaemon(ctx, &daemArgs, &configFile),
|
||||||
}
|
}
|
||||||
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
|
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
|
||||||
|
daemonCmd.Flags().StringVar(&daemArgs.Labels, "labels", os.Getenv("GITEA_RUNNER_LABELS"), "Runner labels, comma separated. Overrides the labels of an already registered runner")
|
||||||
rootCmd.AddCommand(daemonCmd)
|
rootCmd.AddCommand(daemonCmd)
|
||||||
|
|
||||||
// ./gitea-runner exec
|
// ./gitea-runner exec
|
||||||
rootCmd.AddCommand(loadExecCmd(ctx))
|
rootCmd.AddCommand(loadExecCmd(ctx))
|
||||||
|
|
||||||
|
// ./gitea-runner bug-report
|
||||||
|
rootCmd.AddCommand(loadBugReportCmd())
|
||||||
|
|
||||||
// ./gitea-runner config
|
// ./gitea-runner config
|
||||||
rootCmd.AddCommand(&cobra.Command{
|
rootCmd.AddCommand(&cobra.Command{
|
||||||
Use: "generate-config",
|
Use: "generate-config",
|
||||||
|
|||||||
@@ -49,10 +49,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
return fmt.Errorf("failed to load registration file: %w", err)
|
return fmt.Errorf("failed to load registration file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lbls := reg.Labels
|
lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
|
||||||
if len(cfg.Runner.Labels) > 0 {
|
|
||||||
lbls = cfg.Runner.Labels
|
|
||||||
}
|
|
||||||
|
|
||||||
ls := labels.Labels{}
|
ls := labels.Labels{}
|
||||||
for _, l := range lbls {
|
for _, l := range lbls {
|
||||||
@@ -150,17 +147,19 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
}
|
}
|
||||||
runner.SetCapabilitiesFromDeclare(resp)
|
runner.SetCapabilitiesFromDeclare(resp)
|
||||||
|
|
||||||
|
poller := poll.New(cfg, cli, runner)
|
||||||
|
|
||||||
if cfg.Metrics.Enabled {
|
if cfg.Metrics.Enabled {
|
||||||
metrics.Init()
|
metrics.Init()
|
||||||
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
|
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
|
||||||
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
|
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
|
||||||
metrics.RegisterUptimeFunc(time.Now())
|
metrics.RegisterUptimeFunc(time.Now())
|
||||||
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
|
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
|
||||||
metrics.StartServer(ctx, cfg.Metrics.Addr)
|
metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) {
|
||||||
|
return poller.Ready(cfg.Metrics.ReadinessGrace)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
poller := poll.New(cfg, cli, runner)
|
|
||||||
|
|
||||||
if daemArgs.Once || reg.Ephemeral {
|
if daemArgs.Once || reg.Ephemeral {
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -176,7 +175,12 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
} else {
|
} else {
|
||||||
go poller.Poll()
|
go poller.Poll()
|
||||||
|
|
||||||
<-ctx.Done()
|
// Stop either on an external cancellation or when the poller shuts
|
||||||
|
// itself down (e.g. after the runner has been unregistered).
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-poller.Done():
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Msg.Runner.Name, cfg.Runner.ShutdownTimeout)
|
log.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Msg.Runner.Name, cfg.Runner.ShutdownTimeout)
|
||||||
@@ -189,12 +193,39 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
|
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if poller.Unregistered() {
|
||||||
|
return errors.New("runner is no longer registered with the server; please register it again")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type daemonArgs struct {
|
type daemonArgs struct {
|
||||||
Once bool
|
Once bool
|
||||||
|
Labels string
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLabels picks the labels to run with: --labels/GITEA_RUNNER_LABELS > config > .runner.
|
||||||
|
// The flag lets a registered runner change its labels without deleting the .runner file.
|
||||||
|
func resolveLabels(argLabels string, cfgLabels, regLabels []string) []string {
|
||||||
|
if lbls := splitLabels(argLabels); len(lbls) > 0 {
|
||||||
|
return lbls
|
||||||
|
}
|
||||||
|
if len(cfgLabels) > 0 {
|
||||||
|
return cfgLabels
|
||||||
|
}
|
||||||
|
return regLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLabels(s string) []string {
|
||||||
|
var lbls []string
|
||||||
|
for l := range strings.SplitSeq(s, ",") {
|
||||||
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
|
lbls = append(lbls, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lbls
|
||||||
}
|
}
|
||||||
|
|
||||||
// initLogging setup the global logrus logger.
|
// initLogging setup the global logrus logger.
|
||||||
|
|||||||
@@ -12,6 +12,32 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestResolveLabels(t *testing.T) {
|
||||||
|
var (
|
||||||
|
cfgLabels = []string{"cfg:host"}
|
||||||
|
regLabels = []string{"reg:host"}
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
arg string
|
||||||
|
cfg []string
|
||||||
|
reg []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"flag wins", "flag:host,other", cfgLabels, regLabels, []string{"flag:host", "other"}},
|
||||||
|
{"config wins over registration", "", cfgLabels, regLabels, cfgLabels},
|
||||||
|
{"registration is the fallback", "", nil, regLabels, regLabels},
|
||||||
|
{"blank flag is ignored", " , ", cfgLabels, regLabels, cfgLabels},
|
||||||
|
{"nothing configured", "", nil, nil, nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, resolveLabels(tt.arg, tt.cfg, tt.reg))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) {
|
func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) {
|
||||||
got, err := getDockerSocketPath("tcp://docker.example:2376")
|
got, err := getDockerSocketPath("tcp://docker.example:2376")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type executeArgs struct {
|
|||||||
runList bool
|
runList bool
|
||||||
job string
|
job string
|
||||||
event string
|
event string
|
||||||
|
eventpath string
|
||||||
workdir string
|
workdir string
|
||||||
workflowsPath string
|
workflowsPath string
|
||||||
noWorkflowRecurse bool
|
noWorkflowRecurse bool
|
||||||
@@ -441,6 +442,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
|||||||
ArtifactServerPort: execArgs.artifactServerPort,
|
ArtifactServerPort: execArgs.artifactServerPort,
|
||||||
ArtifactServerAddr: execArgs.artifactServerAddr,
|
ArtifactServerAddr: execArgs.artifactServerAddr,
|
||||||
NoSkipCheckout: execArgs.noSkipCheckout,
|
NoSkipCheckout: execArgs.noSkipCheckout,
|
||||||
|
EventPath: execArgs.resolve(execArgs.eventpath),
|
||||||
// PresetGitHubContext: preset,
|
// PresetGitHubContext: preset,
|
||||||
// EventJSON: string(eventJSON),
|
// EventJSON: string(eventJSON),
|
||||||
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
||||||
@@ -496,8 +498,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
|
execCmd.Flags().BoolVarP(&execArg.runList, "list", "l", false, "list workflows")
|
||||||
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID")
|
execCmd.Flags().StringVarP(&execArg.job, "job", "j", "", "run a specific job ID; when several workflow files define that job, also pass --workflows/-W to select the file")
|
||||||
execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
|
execCmd.Flags().StringVarP(&execArg.event, "event", "E", "", "run a event name")
|
||||||
|
execCmd.Flags().StringVarP(&execArg.eventpath, "eventpath", "e", "", "path to a JSON event payload file exposed as the event that triggered the workflow")
|
||||||
execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.gitea/workflows/", "path to workflow file(s)")
|
execCmd.PersistentFlags().StringVarP(&execArg.workflowsPath, "workflows", "W", "./.gitea/workflows/", "path to workflow file(s)")
|
||||||
execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
|
execCmd.PersistentFlags().StringVarP(&execArg.workdir, "directory", "C", ".", "working directory")
|
||||||
execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")
|
execCmd.PersistentFlags().BoolVarP(&execArg.noWorkflowRecurse, "no-recurse", "", false, "Flag to disable running workflows from subdirectories of specified path in '--workflows'/'-W' flag")
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type registerArgs struct {
|
|||||||
NoInteractive bool
|
NoInteractive bool
|
||||||
InstanceAddr string
|
InstanceAddr string
|
||||||
Token string
|
Token string
|
||||||
|
TokenFile string
|
||||||
RunnerName string
|
RunnerName string
|
||||||
Labels string
|
Labels string
|
||||||
Ephemeral bool
|
Ephemeral bool
|
||||||
@@ -93,6 +94,8 @@ const (
|
|||||||
StageExit
|
StageExit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
|
||||||
|
|
||||||
var defaultLabels = []string{
|
var defaultLabels = []string{
|
||||||
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
||||||
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
||||||
@@ -207,10 +210,27 @@ func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *co
|
|||||||
return StageUnknown
|
return StageUnknown
|
||||||
}
|
}
|
||||||
|
|
||||||
func initInputs(regArgs *registerArgs) *registerInputs {
|
func initInputs(regArgs *registerArgs) (*registerInputs, error) {
|
||||||
|
var token string
|
||||||
|
switch {
|
||||||
|
case regArgs.TokenFile != "":
|
||||||
|
tokenBytes, err := os.ReadFile(regArgs.TokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read the token file: %s, %v", regArgs.TokenFile, err)
|
||||||
|
}
|
||||||
|
token = string(tokenBytes)
|
||||||
|
case regArgs.Token != "":
|
||||||
|
token = regArgs.Token
|
||||||
|
default:
|
||||||
|
envToken, ok := os.LookupEnv(registerTokenEnvVar)
|
||||||
|
if !ok || envToken == "" {
|
||||||
|
return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar)
|
||||||
|
}
|
||||||
|
token = envToken
|
||||||
|
}
|
||||||
inputs := ®isterInputs{
|
inputs := ®isterInputs{
|
||||||
InstanceAddr: regArgs.InstanceAddr,
|
InstanceAddr: regArgs.InstanceAddr,
|
||||||
Token: regArgs.Token,
|
Token: token,
|
||||||
RunnerName: regArgs.RunnerName,
|
RunnerName: regArgs.RunnerName,
|
||||||
Ephemeral: regArgs.Ephemeral,
|
Ephemeral: regArgs.Ephemeral,
|
||||||
}
|
}
|
||||||
@@ -219,7 +239,7 @@ func initInputs(regArgs *registerArgs) *registerInputs {
|
|||||||
if regArgs.Labels != "" {
|
if regArgs.Labels != "" {
|
||||||
inputs.Labels = strings.Split(regArgs.Labels, ",")
|
inputs.Labels = strings.Split(regArgs.Labels, ",")
|
||||||
}
|
}
|
||||||
return inputs
|
return inputs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
|
func registerInteractive(ctx context.Context, configFile string, regArgs *registerArgs) error {
|
||||||
@@ -235,7 +255,10 @@ func registerInteractive(ctx context.Context, configFile string, regArgs *regist
|
|||||||
if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() {
|
if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() {
|
||||||
stage = StageOverwriteLocalConfig
|
stage = StageOverwriteLocalConfig
|
||||||
}
|
}
|
||||||
inputs := initInputs(regArgs)
|
inputs, err := initInputs(regArgs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
cmdString := inputs.stageValue(stage)
|
cmdString := inputs.stageValue(stage)
|
||||||
@@ -292,7 +315,10 @@ func registerNoInteractive(ctx context.Context, configFile string, regArgs *regi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inputs := initInputs(regArgs)
|
inputs, err := initInputs(regArgs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// specify labels in config file.
|
// specify labels in config file.
|
||||||
if len(cfg.Runner.Labels) > 0 {
|
if len(cfg.Runner.Labels) > 0 {
|
||||||
if regArgs.Labels != "" {
|
if regArgs.Labels != "" {
|
||||||
@@ -361,7 +387,10 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
|
|||||||
|
|
||||||
ls := make([]string, len(reg.Labels))
|
ls := make([]string, len(reg.Labels))
|
||||||
for i, v := range reg.Labels {
|
for i, v := range reg.Labels {
|
||||||
l, _ := labels.Parse(v)
|
l, err := labels.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse label %q: %w", v, err)
|
||||||
|
}
|
||||||
ls[i] = l.Name
|
ls[i] = l.Name
|
||||||
}
|
}
|
||||||
// register new runner.
|
// register new runner.
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import (
|
|||||||
|
|
||||||
func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
|
func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
|
||||||
err := registerNoInteractive(t.Context(), "", ®isterArgs{
|
err := registerNoInteractive(t.Context(), "", ®isterArgs{
|
||||||
Labels: "label:invalid",
|
Labels: "ubuntu:host,,broken",
|
||||||
Token: "token",
|
Token: "token",
|
||||||
InstanceAddr: "http://localhost:3000",
|
InstanceAddr: "http://localhost:3000",
|
||||||
})
|
})
|
||||||
assert.Error(t, err, "unsupported schema: invalid")
|
assert.ErrorContains(t, err, "empty label")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterInputsValidate(t *testing.T) {
|
func TestRegisterInputsValidate(t *testing.T) {
|
||||||
@@ -40,8 +40,8 @@ func TestRegisterInputsValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid label",
|
name: "invalid label",
|
||||||
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:vm:bad"}},
|
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{""}},
|
||||||
wantErr: "unsupported schema: vm",
|
wantErr: "empty label",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid",
|
name: "valid",
|
||||||
@@ -62,7 +62,9 @@ func TestRegisterInputsValidate(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidateLabels(t *testing.T) {
|
func TestValidateLabels(t *testing.T) {
|
||||||
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
||||||
require.Error(t, validateLabels([]string{"ubuntu:host", "ubuntu:vm:bad"}))
|
// a colon that is not a supported schema is part of the label name
|
||||||
|
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
|
||||||
|
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterInputsStageValue(t *testing.T) {
|
func TestRegisterInputsStageValue(t *testing.T) {
|
||||||
@@ -106,11 +108,10 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("labels from config skip the labels stage", func(t *testing.T) {
|
t.Run("labels from config skip the labels stage", func(t *testing.T) {
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Runner.Labels = []string{"ubuntu:host", "ubuntu:vm:bad"}
|
cfg.Runner.Labels = []string{"ubuntu:host", "", "pool:e57e18d4"}
|
||||||
inputs := ®isterInputs{}
|
inputs := ®isterInputs{}
|
||||||
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
|
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
|
||||||
// only the valid label survives
|
require.Equal(t, []string{"ubuntu:host", "pool:e57e18d4"}, inputs.Labels)
|
||||||
require.Equal(t, []string{"ubuntu:host"}, inputs.Labels)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("blank labels input uses defaults", func(t *testing.T) {
|
t.Run("blank labels input uses defaults", func(t *testing.T) {
|
||||||
@@ -121,10 +122,16 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("invalid labels input loops back", func(t *testing.T) {
|
t.Run("invalid labels input loops back", func(t *testing.T) {
|
||||||
inputs := ®isterInputs{}
|
inputs := ®isterInputs{}
|
||||||
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:vm:bad", emptyCfg))
|
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:host,,bad", emptyCfg))
|
||||||
require.Nil(t, inputs.Labels)
|
require.Nil(t, inputs.Labels)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("labels containing a colon are accepted", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "pool:e57e18d4,ubuntu:host", emptyCfg))
|
||||||
|
require.Equal(t, []string{"pool:e57e18d4", "ubuntu:host"}, inputs.Labels)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("overwrite local config", func(t *testing.T) {
|
t.Run("overwrite local config", func(t *testing.T) {
|
||||||
inputs := ®isterInputs{}
|
inputs := ®isterInputs{}
|
||||||
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))
|
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))
|
||||||
@@ -139,18 +146,103 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInitInputs(t *testing.T) {
|
func TestInitInputs(t *testing.T) {
|
||||||
inputs := initInputs(®isterArgs{
|
t.Run("missing token", func(t *testing.T) {
|
||||||
|
_, err := initInputs(®isterArgs{
|
||||||
InstanceAddr: "http://localhost:3000",
|
InstanceAddr: "http://localhost:3000",
|
||||||
Token: "token",
|
|
||||||
RunnerName: "runner",
|
RunnerName: "runner",
|
||||||
Ephemeral: true,
|
Ephemeral: true,
|
||||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
})
|
})
|
||||||
|
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty token", func(t *testing.T) {
|
||||||
|
t.Setenv(registerTokenEnvVar, "")
|
||||||
|
_, err := initInputs(®isterArgs{
|
||||||
|
InstanceAddr: "http://localhost:3000",
|
||||||
|
Token: "",
|
||||||
|
TokenFile: "",
|
||||||
|
RunnerName: "runner",
|
||||||
|
Ephemeral: true,
|
||||||
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
|
})
|
||||||
|
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid token file", func(t *testing.T) {
|
||||||
|
t.Setenv(registerTokenEnvVar, "from-env")
|
||||||
|
_, err := initInputs(®isterArgs{
|
||||||
|
InstanceAddr: "http://localhost:3000",
|
||||||
|
TokenFile: "/tmp/nonexistent",
|
||||||
|
RunnerName: "runner",
|
||||||
|
Ephemeral: true,
|
||||||
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
|
})
|
||||||
|
require.EqualError(t, err, "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid token", func(t *testing.T) {
|
||||||
|
t.Setenv(registerTokenEnvVar, "from-env")
|
||||||
|
inputs, err := initInputs(®isterArgs{
|
||||||
|
InstanceAddr: "http://localhost:3000",
|
||||||
|
Token: "from-plain-arg",
|
||||||
|
RunnerName: "runner",
|
||||||
|
Ephemeral: true,
|
||||||
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||||
require.Equal(t, "token", inputs.Token)
|
require.Equal(t, "from-plain-arg", inputs.Token)
|
||||||
require.Equal(t, "runner", inputs.RunnerName)
|
require.Equal(t, "runner", inputs.RunnerName)
|
||||||
require.True(t, inputs.Ephemeral)
|
require.True(t, inputs.Ephemeral)
|
||||||
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
||||||
|
})
|
||||||
|
|
||||||
require.Nil(t, initInputs(®isterArgs{Labels: " "}).Labels)
|
t.Run("valid token file", func(t *testing.T) {
|
||||||
|
t.Setenv(registerTokenEnvVar, "from-env")
|
||||||
|
tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file")
|
||||||
|
require.NoError(t, createErr)
|
||||||
|
defer tokenFile.Close()
|
||||||
|
_, writeErr := tokenFile.WriteString("from-file")
|
||||||
|
require.NoError(t, writeErr)
|
||||||
|
_ = tokenFile.Sync()
|
||||||
|
|
||||||
|
inputs, err := initInputs(®isterArgs{
|
||||||
|
InstanceAddr: "http://localhost:3000",
|
||||||
|
TokenFile: tokenFile.Name(),
|
||||||
|
RunnerName: "runner",
|
||||||
|
Ephemeral: true,
|
||||||
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||||
|
require.Equal(t, "from-file", inputs.Token)
|
||||||
|
require.Equal(t, "runner", inputs.RunnerName)
|
||||||
|
require.True(t, inputs.Ephemeral)
|
||||||
|
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token from environment variable", func(t *testing.T) {
|
||||||
|
t.Setenv(registerTokenEnvVar, "from-env")
|
||||||
|
inputs, err := initInputs(®isterArgs{
|
||||||
|
InstanceAddr: "http://localhost:3000",
|
||||||
|
RunnerName: "runner",
|
||||||
|
Ephemeral: true,
|
||||||
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||||
|
require.Equal(t, "from-env", inputs.Token)
|
||||||
|
require.Equal(t, "runner", inputs.RunnerName)
|
||||||
|
require.True(t, inputs.Ephemeral)
|
||||||
|
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty labels", func(t *testing.T) {
|
||||||
|
inputs, _ := initInputs(®isterArgs{
|
||||||
|
Token: "from-plain-arg",
|
||||||
|
Labels: " ",
|
||||||
|
})
|
||||||
|
require.Nil(t, inputs.Labels)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ type IdleRunner interface {
|
|||||||
OnIdle(ctx context.Context)
|
OnIdle(ctx context.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AvailabilityRunner can temporarily pause task fetching for local resource
|
||||||
|
// conditions such as low disk space without changing server-side scheduling.
|
||||||
|
type AvailabilityRunner interface {
|
||||||
|
CanAcceptTask(ctx context.Context) (bool, string)
|
||||||
|
}
|
||||||
|
|
||||||
type Poller struct {
|
type Poller struct {
|
||||||
client client.Client
|
client client.Client
|
||||||
runner TaskRunner
|
runner TaskRunner
|
||||||
@@ -45,6 +51,15 @@ type Poller struct {
|
|||||||
shutdownJobs context.CancelFunc
|
shutdownJobs context.CancelFunc
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
|
||||||
|
// unregistered is set when the server rejects the runner with an
|
||||||
|
// Unauthenticated response, meaning the runner is no longer registered.
|
||||||
|
unregistered atomic.Bool
|
||||||
|
lastHealthyPoll atomic.Int64
|
||||||
|
lastPollFailed atomic.Bool
|
||||||
|
availabilityMu sync.Mutex
|
||||||
|
availabilityReady bool
|
||||||
|
availabilityReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// workerState holds the single poller's backoff state. Consecutive empty or
|
// workerState holds the single poller's backoff state. Consecutive empty or
|
||||||
@@ -66,7 +81,7 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
|
|||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|
||||||
return &Poller{
|
p := &Poller{
|
||||||
client: client,
|
client: client,
|
||||||
runner: runner,
|
runner: runner,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -79,6 +94,10 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
|
|||||||
|
|
||||||
done: done,
|
done: done,
|
||||||
}
|
}
|
||||||
|
p.lastHealthyPoll.Store(time.Now().UnixNano())
|
||||||
|
p.availabilityReady = true
|
||||||
|
p.availabilityReason = "ok"
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Poller) Poll() {
|
func (p *Poller) Poll() {
|
||||||
@@ -98,6 +117,17 @@ func (p *Poller) Poll() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ready, reason := p.localAvailability(p.pollingCtx)
|
||||||
|
p.reportAvailability(ready, reason)
|
||||||
|
if !ready {
|
||||||
|
p.runIdleMaintenance()
|
||||||
|
<-sem
|
||||||
|
if !p.waitBackoff(s) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
task, ok := p.fetchTask(p.pollingCtx, s)
|
task, ok := p.fetchTask(p.pollingCtx, s)
|
||||||
if !ok {
|
if !ok {
|
||||||
p.runIdleMaintenance()
|
p.runIdleMaintenance()
|
||||||
@@ -123,6 +153,15 @@ func (p *Poller) PollOnce() {
|
|||||||
defer close(p.done)
|
defer close(p.done)
|
||||||
s := &workerState{}
|
s := &workerState{}
|
||||||
for {
|
for {
|
||||||
|
ready, reason := p.localAvailability(p.pollingCtx)
|
||||||
|
p.reportAvailability(ready, reason)
|
||||||
|
if !ready {
|
||||||
|
p.runIdleMaintenance()
|
||||||
|
if !p.waitBackoff(s) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
task, ok := p.fetchTask(p.pollingCtx, s)
|
task, ok := p.fetchTask(p.pollingCtx, s)
|
||||||
if !ok {
|
if !ok {
|
||||||
p.runIdleMaintenance()
|
p.runIdleMaintenance()
|
||||||
@@ -137,6 +176,61 @@ func (p *Poller) PollOnce() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Done returns a channel that is closed once polling has fully stopped,
|
||||||
|
// allowing callers to react when the poller shuts itself down (e.g. after the
|
||||||
|
// runner has been unregistered) rather than only on an external cancellation.
|
||||||
|
func (p *Poller) Done() <-chan struct{} {
|
||||||
|
return p.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregistered reports whether polling stopped because the server rejected the
|
||||||
|
// runner as unregistered (an Unauthenticated response).
|
||||||
|
func (p *Poller) Unregistered() bool {
|
||||||
|
return p.unregistered.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready reports whether the daemon can currently communicate with Gitea and
|
||||||
|
// accept work, reusing the availability the poll loop last observed rather than
|
||||||
|
// re-running the check. Transient transport failures are tolerated for grace.
|
||||||
|
func (p *Poller) Ready(grace time.Duration) (bool, string) {
|
||||||
|
if p.unregistered.Load() {
|
||||||
|
return false, "runner is no longer registered"
|
||||||
|
}
|
||||||
|
p.availabilityMu.Lock()
|
||||||
|
ready, reason := p.availabilityReady, p.availabilityReason
|
||||||
|
p.availabilityMu.Unlock()
|
||||||
|
if !ready {
|
||||||
|
return false, reason
|
||||||
|
}
|
||||||
|
if !p.lastPollFailed.Load() {
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
if time.Since(time.Unix(0, p.lastHealthyPoll.Load())) <= grace {
|
||||||
|
return true, "polling errors within grace period"
|
||||||
|
}
|
||||||
|
return false, "unable to poll Gitea"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) localAvailability(ctx context.Context) (bool, string) {
|
||||||
|
if available, ok := p.runner.(AvailabilityRunner); ok {
|
||||||
|
return available.CanAcceptTask(ctx)
|
||||||
|
}
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) reportAvailability(ready bool, reason string) {
|
||||||
|
p.availabilityMu.Lock()
|
||||||
|
defer p.availabilityMu.Unlock()
|
||||||
|
switch {
|
||||||
|
case !ready && p.availabilityReady:
|
||||||
|
log.Warnf("runner temporarily unavailable: %s", reason)
|
||||||
|
case ready && !p.availabilityReady:
|
||||||
|
log.Info("runner local health recovered, resuming task polling")
|
||||||
|
}
|
||||||
|
p.availabilityReady = ready
|
||||||
|
p.availabilityReason = reason
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Poller) runIdleMaintenance() {
|
func (p *Poller) runIdleMaintenance() {
|
||||||
if idleRunner, ok := p.runner.(IdleRunner); ok {
|
if idleRunner, ok := p.runner.(IdleRunner); ok {
|
||||||
idleRunner.OnIdle(p.jobsCtx)
|
idleRunner.OnIdle(p.jobsCtx)
|
||||||
@@ -256,6 +350,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
|||||||
// found no work within FetchTimeout. Treat it as an empty response and do
|
// found no work within FetchTimeout. Treat it as an empty response and do
|
||||||
// not record the duration — the timeout value would swamp the histogram.
|
// not record the duration — the timeout value would swamp the histogram.
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
p.markHealthyPoll()
|
||||||
s.consecutiveEmpty++
|
s.consecutiveEmpty++
|
||||||
s.consecutiveErrors = 0 // timeout is a healthy idle response
|
s.consecutiveErrors = 0 // timeout is a healthy idle response
|
||||||
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
|
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
|
||||||
@@ -264,12 +359,23 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
|||||||
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
|
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// An Unauthenticated response means the server no longer knows this
|
||||||
|
// runner (e.g. it was deleted). Retrying forever is pointless, so stop
|
||||||
|
// polling and let the daemon exit with an error instead of spinning.
|
||||||
|
if connect.CodeOf(err) == connect.CodeUnauthenticated {
|
||||||
|
log.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
|
||||||
|
p.unregistered.Store(true)
|
||||||
|
p.shutdownPolling()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
log.WithError(err).Error("failed to fetch task")
|
log.WithError(err).Error("failed to fetch task")
|
||||||
|
p.lastPollFailed.Store(true)
|
||||||
s.consecutiveErrors++
|
s.consecutiveErrors++
|
||||||
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
|
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
|
||||||
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask).Inc()
|
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask).Inc()
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
p.markHealthyPoll()
|
||||||
|
|
||||||
// Successful response — reset error counter.
|
// Successful response — reset error counter.
|
||||||
s.consecutiveErrors = 0
|
s.consecutiveErrors = 0
|
||||||
@@ -296,3 +402,8 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
|||||||
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
|
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
|
||||||
return resp.Msg.Task, true
|
return resp.Msg.Task, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Poller) markHealthyPoll() {
|
||||||
|
p.lastHealthyPoll.Store(time.Now().UnixNano())
|
||||||
|
p.lastPollFailed.Store(false)
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,35 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
|
|||||||
assert.Equal(t, int64(0), s.consecutiveEmpty)
|
assert.Equal(t, int64(0), s.consecutiveEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPoller_FetchUnauthenticatedStopsPolling verifies that an Unauthenticated
|
||||||
|
// response marks the runner as unregistered and cancels the polling context so
|
||||||
|
// the daemon can exit instead of retrying forever.
|
||||||
|
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
|
||||||
|
client := mocks.NewClient(t)
|
||||||
|
client.On("FetchTask", mock.Anything, mock.Anything).Return(
|
||||||
|
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
|
||||||
|
return nil, connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
p := New(cfg, client, nil)
|
||||||
|
|
||||||
|
s := &workerState{}
|
||||||
|
_, ok := p.fetchTask(context.Background(), s)
|
||||||
|
require.False(t, ok)
|
||||||
|
|
||||||
|
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
|
||||||
|
assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-p.pollingCtx.Done():
|
||||||
|
default:
|
||||||
|
t.Fatal("expected polling context to be cancelled after an Unauthenticated response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestPoller_CalculateInterval verifies the exponential backoff math is
|
// TestPoller_CalculateInterval verifies the exponential backoff math is
|
||||||
// correctly driven by the workerState counters.
|
// correctly driven by the workerState counters.
|
||||||
func TestPoller_CalculateInterval(t *testing.T) {
|
func TestPoller_CalculateInterval(t *testing.T) {
|
||||||
@@ -130,6 +159,81 @@ type idleAwareRunner struct {
|
|||||||
idleCalls atomic.Int64
|
idleCalls atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type availabilityRunner struct {
|
||||||
|
mockRunner
|
||||||
|
ready atomic.Bool
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *availabilityRunner) CanAcceptTask(_ context.Context) (bool, string) {
|
||||||
|
if r.ready.Load() {
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
return false, r.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollerReady(t *testing.T) {
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
poller := New(cfg, nil, &availabilityRunner{})
|
||||||
|
|
||||||
|
// /readyz reuses the availability the poll loop last recorded.
|
||||||
|
ready, reason := poller.Ready(time.Second)
|
||||||
|
assert.True(t, ready)
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
|
||||||
|
poller.reportAvailability(false, "low disk space")
|
||||||
|
ready, reason = poller.Ready(time.Second)
|
||||||
|
assert.False(t, ready)
|
||||||
|
assert.Equal(t, "low disk space", reason)
|
||||||
|
|
||||||
|
poller.reportAvailability(true, "ok")
|
||||||
|
poller.lastPollFailed.Store(true)
|
||||||
|
poller.lastHealthyPoll.Store(time.Now().UnixNano())
|
||||||
|
ready, _ = poller.Ready(time.Second)
|
||||||
|
assert.True(t, ready, "transient polling errors should remain ready during grace")
|
||||||
|
|
||||||
|
poller.lastHealthyPoll.Store(time.Now().Add(-2 * time.Second).UnixNano())
|
||||||
|
ready, reason = poller.Ready(time.Second)
|
||||||
|
assert.False(t, ready)
|
||||||
|
assert.Equal(t, "unable to poll Gitea", reason)
|
||||||
|
|
||||||
|
poller.unregistered.Store(true)
|
||||||
|
ready, reason = poller.Ready(time.Second)
|
||||||
|
assert.False(t, ready)
|
||||||
|
assert.Equal(t, "runner is no longer registered", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollerPausesAndResumesForLocalAvailability(t *testing.T) {
|
||||||
|
var fetches atomic.Int64
|
||||||
|
cli := mocks.NewClient(t)
|
||||||
|
cli.On("FetchTask", mock.Anything, mock.Anything).Maybe().Run(func(mock.Arguments) {
|
||||||
|
fetches.Add(1)
|
||||||
|
}).Return(connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil)
|
||||||
|
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg.Runner.FetchInterval = 10 * time.Millisecond
|
||||||
|
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
|
||||||
|
runner := &availabilityRunner{reason: "low disk space"}
|
||||||
|
poller := New(cfg, cli, runner)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(poller.Poll)
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
assert.Zero(t, fetches.Load(), "an unavailable runner must not fetch a task")
|
||||||
|
|
||||||
|
runner.ready.Store(true)
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return fetches.Load() > 0
|
||||||
|
}, time.Second, 10*time.Millisecond, "polling should resume after local recovery")
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
require.NoError(t, poller.Shutdown(shutdownCtx))
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error {
|
func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error {
|
||||||
atomicMax(&m.maxConcurrent, m.running.Add(1))
|
atomicMax(&m.maxConcurrent, m.running.Add(1))
|
||||||
select {
|
select {
|
||||||
|
|||||||
12
internal/app/run/disk_other.go
Normal file
12
internal/app/run/disk_other.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func freeDiskBytes(path string) (uint64, error) {
|
||||||
|
return 0, fmt.Errorf("free disk space checks are not supported for %s", path)
|
||||||
|
}
|
||||||
53
internal/app/run/disk_test.go
Normal file
53
internal/app/run/disk_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanAcceptTaskDiskGuard(t *testing.T) {
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg.Host.WorkdirParent = t.TempDir()
|
||||||
|
cfg.HealthCheck.Enabled = true
|
||||||
|
r := &Runner{cfg: cfg}
|
||||||
|
|
||||||
|
ready, _ := r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready)
|
||||||
|
|
||||||
|
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
|
||||||
|
ready, reason := r.CanAcceptTask(t.Context())
|
||||||
|
assert.False(t, ready)
|
||||||
|
assert.Contains(t, reason, "low disk space")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskCheckDeferredWhileJobRuns(t *testing.T) {
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg.HealthCheck.Enabled = true
|
||||||
|
cfg.HealthCheck.MinFreeDiskSpaceMB = 1
|
||||||
|
cfg.Host.WorkdirParent = t.TempDir()
|
||||||
|
r := &Runner{cfg: cfg}
|
||||||
|
|
||||||
|
ready, reason := r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready)
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
|
||||||
|
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
|
||||||
|
r.runningCount.Store(1)
|
||||||
|
ready, reason = r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready, "the disk check must not run while a job is active")
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
|
||||||
|
r.runningCount.Store(0)
|
||||||
|
ready, reason = r.CanAcceptTask(t.Context())
|
||||||
|
assert.False(t, ready)
|
||||||
|
assert.Contains(t, reason, "low disk space")
|
||||||
|
}
|
||||||
16
internal/app/run/disk_unix.go
Normal file
16
internal/app/run/disk_unix.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
func freeDiskBytes(path string) (uint64, error) {
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if err := unix.Statfs(path, &stat); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(stat.Bavail) * uint64(stat.Bsize), nil //nolint:unconvert // Bavail/Bsize signedness differs by platform
|
||||||
|
}
|
||||||
20
internal/app/run/disk_windows.go
Normal file
20
internal/app/run/disk_windows.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import "golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
func freeDiskBytes(path string) (uint64, error) {
|
||||||
|
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var available uint64
|
||||||
|
if err := windows.GetDiskFreeSpaceEx(pathPtr, &available, nil, nil); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return available, nil
|
||||||
|
}
|
||||||
106
internal/app/run/health_check.go
Normal file
106
internal/app/run/health_check.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Runner) checkConfiguredHealth(ctx context.Context) (bool, string) {
|
||||||
|
script := r.cfg.HealthCheck.Script
|
||||||
|
if script == "" {
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if r.now != nil {
|
||||||
|
now = r.now()
|
||||||
|
}
|
||||||
|
if !r.healthCheckLast.IsZero() && now.Sub(r.healthCheckLast) < r.cfg.HealthCheck.Interval {
|
||||||
|
return r.healthCheckReady, r.healthCheckReason
|
||||||
|
}
|
||||||
|
|
||||||
|
env := processEnvironment()
|
||||||
|
maps.Copy(env, r.cloneEnvs())
|
||||||
|
env["GITEA_RUNNER_HEALTH_CHECK"] = "true"
|
||||||
|
env["GITEA_RUNNER_NAME"] = r.name
|
||||||
|
if r.client != nil {
|
||||||
|
env["GITEA_INSTANCE_URL"] = r.client.Address()
|
||||||
|
}
|
||||||
|
env["GITEA_RUNNER_RUNNING_JOBS"] = strconv.FormatInt(r.RunningCount(), 10)
|
||||||
|
|
||||||
|
runner := r.runHealthCheck
|
||||||
|
if runner == nil {
|
||||||
|
runner = executeHealthCheck
|
||||||
|
}
|
||||||
|
err := runner(ctx, script, r.cfg.HealthCheck.Timeout, env)
|
||||||
|
r.healthCheckLast = now
|
||||||
|
r.healthCheckReady = err == nil
|
||||||
|
if err != nil {
|
||||||
|
r.healthCheckReason = "runner health check failed: " + err.Error()
|
||||||
|
} else {
|
||||||
|
r.healthCheckReason = "ok"
|
||||||
|
}
|
||||||
|
return r.healthCheckReady, r.healthCheckReason
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeHealthCheck(ctx context.Context, script string, timeout time.Duration, env map[string]string) error {
|
||||||
|
checkCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(checkCtx, script)
|
||||||
|
cmd.Env = envListFromMap(env)
|
||||||
|
cmd.SysProcAttr = process.SysProcAttr(script, false)
|
||||||
|
writer := common.NewLineWriter(func(line string) bool {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if line != "" {
|
||||||
|
common.Logger(ctx).Infof("health check: %s", line)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
cmd.Stdout = writer
|
||||||
|
cmd.Stderr = writer
|
||||||
|
|
||||||
|
treeKill := process.NewTreeKill(cmd)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
if killer, err := treeKill.Capture(cmd.Process); err == nil {
|
||||||
|
defer killer.Close()
|
||||||
|
}
|
||||||
|
err := cmd.Wait()
|
||||||
|
common.FlushWriter(writer)
|
||||||
|
if errors.Is(checkCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
return fmt.Errorf("timed out after %s", timeout)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return fmt.Errorf("exited with code %d", exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func processEnvironment() map[string]string {
|
||||||
|
environ := os.Environ()
|
||||||
|
env := make(map[string]string, len(environ))
|
||||||
|
for _, value := range environ {
|
||||||
|
if key, item, ok := strings.Cut(value, "="); ok {
|
||||||
|
env[key] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
148
internal/app/run/health_check_test.go
Normal file
148
internal/app/run/health_check_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfiguredHealthCheckCachesAndRecovers(t *testing.T) {
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg.HealthCheck.Enabled = true
|
||||||
|
cfg.HealthCheck.Script = "/health-check"
|
||||||
|
cfg.HealthCheck.Interval = time.Minute
|
||||||
|
cfg.HealthCheck.Timeout = time.Second
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
calls := 0
|
||||||
|
fail := true
|
||||||
|
r := &Runner{
|
||||||
|
cfg: cfg,
|
||||||
|
name: "runner-1",
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
runHealthCheck: func(_ context.Context, script string, timeout time.Duration, env map[string]string) error {
|
||||||
|
calls++
|
||||||
|
assert.Equal(t, "/health-check", script)
|
||||||
|
assert.Equal(t, time.Second, timeout)
|
||||||
|
assert.Equal(t, "true", env["GITEA_RUNNER_HEALTH_CHECK"])
|
||||||
|
assert.Equal(t, "runner-1", env["GITEA_RUNNER_NAME"])
|
||||||
|
if fail {
|
||||||
|
return errors.New("unhealthy")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ready, reason := r.CanAcceptTask(t.Context())
|
||||||
|
assert.False(t, ready)
|
||||||
|
assert.Contains(t, reason, "unhealthy")
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
|
||||||
|
fail = false
|
||||||
|
ready, _ = r.CanAcceptTask(t.Context())
|
||||||
|
assert.False(t, ready, "the failed result should remain cached")
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
|
||||||
|
now = now.Add(time.Minute)
|
||||||
|
ready, reason = r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready)
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
assert.Equal(t, 2, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredHealthCheckDisabled(t *testing.T) {
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg.HealthCheck.MinFreeDiskSpaceMB = 1 << 40
|
||||||
|
cfg.HealthCheck.Script = "/must-not-run"
|
||||||
|
r := &Runner{
|
||||||
|
cfg: cfg,
|
||||||
|
runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error {
|
||||||
|
t.Fatal("disabled health check executed")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ready, reason := r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready)
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredHealthCheckDeferredWhileJobRuns(t *testing.T) {
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg.HealthCheck.Enabled = true
|
||||||
|
cfg.HealthCheck.Script = "/health-check"
|
||||||
|
cfg.HealthCheck.Interval = time.Minute
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
calls := 0
|
||||||
|
fail := false
|
||||||
|
r := &Runner{
|
||||||
|
cfg: cfg,
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error {
|
||||||
|
calls++
|
||||||
|
if fail {
|
||||||
|
return errors.New("unhealthy")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ready, reason := r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready)
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
|
||||||
|
now = now.Add(time.Minute)
|
||||||
|
fail = true
|
||||||
|
r.runningCount.Store(1)
|
||||||
|
ready, reason = r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready, "the last result should be reused while a job runs")
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
|
||||||
|
r.runningCount.Store(0)
|
||||||
|
ready, reason = r.CanAcceptTask(t.Context())
|
||||||
|
assert.False(t, ready)
|
||||||
|
assert.Contains(t, reason, "unhealthy")
|
||||||
|
assert.Equal(t, 2, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredHealthCheckInitialRunDeferredWhileJobRuns(t *testing.T) {
|
||||||
|
cfg, err := config.LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg.HealthCheck.Enabled = true
|
||||||
|
cfg.HealthCheck.Script = "/health-check"
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
r := &Runner{
|
||||||
|
cfg: cfg,
|
||||||
|
runHealthCheck: func(_ context.Context, _ string, _ time.Duration, _ map[string]string) error {
|
||||||
|
calls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r.runningCount.Store(1)
|
||||||
|
|
||||||
|
ready, reason := r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready)
|
||||||
|
assert.Contains(t, reason, "deferred")
|
||||||
|
assert.Zero(t, calls)
|
||||||
|
|
||||||
|
r.runningCount.Store(0)
|
||||||
|
ready, reason = r.CanAcceptTask(t.Context())
|
||||||
|
assert.True(t, ready)
|
||||||
|
assert.Equal(t, "ok", reason)
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
}
|
||||||
@@ -66,6 +66,13 @@ type Runner struct {
|
|||||||
runningCount atomic.Int64
|
runningCount atomic.Int64
|
||||||
lastIdleCleanupUnixNano atomic.Int64
|
lastIdleCleanupUnixNano atomic.Int64
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
healthCheckLast time.Time
|
||||||
|
healthCheckReady bool
|
||||||
|
healthCheckReason string
|
||||||
|
healthStatusSet bool
|
||||||
|
healthStatusReady bool
|
||||||
|
healthStatusReason string
|
||||||
|
runHealthCheck func(context.Context, string, time.Duration, map[string]string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner {
|
func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner {
|
||||||
@@ -116,6 +123,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
envs: envs,
|
envs: envs,
|
||||||
cacheHandler: cacheHandler,
|
cacheHandler: cacheHandler,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
|
runHealthCheck: executeHealthCheck,
|
||||||
}
|
}
|
||||||
return runner
|
return runner
|
||||||
}
|
}
|
||||||
@@ -315,7 +323,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
reporter.Logf("%s(version:%s) received task %v of job %v, be triggered by event: %s", r.name, ver.Version(), task.Id, task.Context.Fields["job"].GetStringValue(), task.Context.Fields["event_name"].GetStringValue())
|
r.reportSetup(reporter, task)
|
||||||
|
|
||||||
workflow, jobID, err := generateWorkflow(task)
|
workflow, jobID, err := generateWorkflow(task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -445,6 +453,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
|
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
|
||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
NoSkipCheckout: true,
|
NoSkipCheckout: true,
|
||||||
|
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
|
||||||
PresetGitHubContext: preset,
|
PresetGitHubContext: preset,
|
||||||
EventJSON: string(eventJSON),
|
EventJSON: string(eventJSON),
|
||||||
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
|
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
|
||||||
@@ -578,6 +587,67 @@ func (r *Runner) RunningCount() int64 {
|
|||||||
return r.runningCount.Load()
|
return r.runningCount.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanAcceptTask checks local admission conditions without consuming a task. It is
|
||||||
|
// called only from the poll loop, so the cached health fields need no lock.
|
||||||
|
func (r *Runner) CanAcceptTask(ctx context.Context) (bool, string) {
|
||||||
|
if !r.cfg.HealthCheck.Enabled {
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.RunningCount() > 0 {
|
||||||
|
if !r.healthStatusSet {
|
||||||
|
return true, "health checks deferred while jobs are running"
|
||||||
|
}
|
||||||
|
return r.healthStatusReady, r.healthStatusReason
|
||||||
|
}
|
||||||
|
|
||||||
|
if ready, reason := checkFreeDisk(r.cfg); !ready {
|
||||||
|
r.setHealthStatus(ready, reason)
|
||||||
|
return false, reason
|
||||||
|
}
|
||||||
|
ready, reason := r.checkConfiguredHealth(ctx)
|
||||||
|
r.setHealthStatus(ready, reason)
|
||||||
|
return ready, reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) setHealthStatus(ready bool, reason string) {
|
||||||
|
r.healthStatusSet = true
|
||||||
|
r.healthStatusReady = ready
|
||||||
|
r.healthStatusReason = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFreeDisk evaluates the configured task-admission disk threshold.
|
||||||
|
func checkFreeDisk(cfg *config.Config) (bool, string) {
|
||||||
|
root := cfg.Host.WorkdirParent
|
||||||
|
if cfg.Container.BindWorkdir {
|
||||||
|
root = filepath.FromSlash("/" + strings.TrimLeft(cfg.Container.WorkdirParent, "/"))
|
||||||
|
}
|
||||||
|
root = nearestExistingPath(root)
|
||||||
|
available, err := freeDiskBytes(root)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("cannot determine free disk space for %s: %v", root, err)
|
||||||
|
}
|
||||||
|
availableMB := available / (1024 * 1024)
|
||||||
|
if availableMB < uint64(cfg.HealthCheck.MinFreeDiskSpaceMB) {
|
||||||
|
return false, fmt.Sprintf("low disk space on %s: %d MiB available, %d MiB required", root, availableMB, cfg.HealthCheck.MinFreeDiskSpaceMB)
|
||||||
|
}
|
||||||
|
return true, "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearestExistingPath(path string) string {
|
||||||
|
for path != "" {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(path)
|
||||||
|
if parent == path {
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
path = parent
|
||||||
|
}
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Response[runnerv1.DeclareResponse], error) {
|
func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Response[runnerv1.DeclareResponse], error) {
|
||||||
return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{
|
return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{
|
||||||
Version: ver.Version(),
|
Version: ver.Version(),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
|
|||||||
cfg.Runner.Envs = map[string]string{"EXISTING": "value"}
|
cfg.Runner.Envs = map[string]string{"EXISTING": "value"}
|
||||||
reg := &config.Registration{
|
reg := &config.Registration{
|
||||||
Name: "runner",
|
Name: "runner",
|
||||||
Labels: []string{"ubuntu:host", "bad:vm:label"},
|
Labels: []string{"ubuntu:host", "", "pool:e57e18d4"},
|
||||||
}
|
}
|
||||||
cli := clientmocks.NewClient(t)
|
cli := clientmocks.NewClient(t)
|
||||||
cli.On("Address").Return("https://gitea.example/").Maybe()
|
cli.On("Address").Return("https://gitea.example/").Maybe()
|
||||||
@@ -83,7 +83,8 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
|
|||||||
r := NewRunner(cfg, reg, cli)
|
r := NewRunner(cfg, reg, cli)
|
||||||
|
|
||||||
require.Equal(t, "runner", r.name)
|
require.Equal(t, "runner", r.name)
|
||||||
require.Len(t, r.labels, 1)
|
require.Len(t, r.labels, 2)
|
||||||
|
require.Equal(t, []string{"ubuntu", "pool:e57e18d4"}, r.labels.Names())
|
||||||
require.Equal(t, "value", r.envs["EXISTING"])
|
require.Equal(t, "value", r.envs["EXISTING"])
|
||||||
require.Equal(t, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"])
|
require.Equal(t, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"])
|
||||||
require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
|
require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
|
||||||
|
|||||||
83
internal/app/run/setup.go
Normal file
83
internal/app/run/setup.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// osReleasePath describes the host distribution on Linux; absent elsewhere, where the platform
|
||||||
|
// falls back to the Go runtime alone. A var so tests can point it at a fixture.
|
||||||
|
var osReleasePath = "/etc/os-release"
|
||||||
|
|
||||||
|
// reportSetup opens the job log the way actions/runner opens its "Set up job" step. The action
|
||||||
|
// downloads and the closing job name are written later, as the job starts.
|
||||||
|
func (r *Runner) reportSetup(reporter *report.Reporter, task *runnerv1.Task) {
|
||||||
|
for _, line := range r.setupLines(task) {
|
||||||
|
reporter.Logf("%s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupLines names the runner, then reports what it was asked to run and the host it runs on, each
|
||||||
|
// in its own group.
|
||||||
|
func (r *Runner) setupLines(task *runnerv1.Task) []string {
|
||||||
|
fields := task.Context.Fields
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("%s(version:%s)", r.name, ver.Version()),
|
||||||
|
"::group::Runner Information",
|
||||||
|
}
|
||||||
|
if names := r.labels.Names(); len(names) > 0 {
|
||||||
|
lines = append(lines, "Runner labels: "+strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
// The task id correlates the job log with the runner's log and the server's task list.
|
||||||
|
fmt.Sprintf("Task: %d", task.Id),
|
||||||
|
"Job: "+fields["job"].GetStringValue(),
|
||||||
|
"Repository: "+fields["repository"].GetStringValue(),
|
||||||
|
"Triggered by event: "+fields["event_name"].GetStringValue(),
|
||||||
|
"::endgroup::",
|
||||||
|
"::group::Operating System",
|
||||||
|
)
|
||||||
|
lines = append(lines, osInfo()...)
|
||||||
|
return append(lines, "::endgroup::")
|
||||||
|
}
|
||||||
|
|
||||||
|
// osInfo describes the host the runner executes on.
|
||||||
|
func osInfo() []string {
|
||||||
|
lines := make([]string, 0, 2)
|
||||||
|
if name := prettyOSName(); name != "" {
|
||||||
|
lines = append(lines, name)
|
||||||
|
}
|
||||||
|
return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH))
|
||||||
|
}
|
||||||
|
|
||||||
|
// prettyOSName reads PRETTY_NAME (e.g. "Ubuntu 24.04.4 LTS") from os-release, or "" when absent.
|
||||||
|
func prettyOSName() string {
|
||||||
|
data, err := os.ReadFile(osReleasePath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok || key != "PRETTY_NAME" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Values are shell-quoted, but the quotes are optional.
|
||||||
|
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||||
|
return unquoted
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
103
internal/app/run/setup_test.go
Normal file
103
internal/app/run/setup_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupLines(t *testing.T) {
|
||||||
|
original := osReleasePath
|
||||||
|
path := filepath.Join(t.TempDir(), "os-release")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("PRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n"), 0o600))
|
||||||
|
osReleasePath = path
|
||||||
|
defer func() { osReleasePath = original }()
|
||||||
|
|
||||||
|
r := &Runner{
|
||||||
|
name: "gitea-com-gitea-0003",
|
||||||
|
labels: labels.Labels{
|
||||||
|
{Name: "ubuntu-latest", Schema: labels.SchemeDocker, Arg: "//node:20"},
|
||||||
|
{Name: "ubuntu-22.04", Schema: labels.SchemeDocker, Arg: "//node:20"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
taskCtx, err := structpb.NewStruct(map[string]any{
|
||||||
|
"job": "lint",
|
||||||
|
"repository": "gitea/runner",
|
||||||
|
"event_name": "pull_request",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"gitea-com-gitea-0003(version:" + ver.Version() + ")",
|
||||||
|
"::group::Runner Information",
|
||||||
|
"Runner labels: ubuntu-latest, ubuntu-22.04",
|
||||||
|
"Task: 268506",
|
||||||
|
"Job: lint",
|
||||||
|
"Repository: gitea/runner",
|
||||||
|
"Triggered by event: pull_request",
|
||||||
|
"::endgroup::",
|
||||||
|
"::group::Operating System",
|
||||||
|
"Ubuntu 24.04.4 LTS",
|
||||||
|
fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
|
||||||
|
"::endgroup::",
|
||||||
|
}, r.setupLines(&runnerv1.Task{Id: 268506, Context: taskCtx}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrettyOSName(t *testing.T) {
|
||||||
|
tests := map[string]struct {
|
||||||
|
osRelease string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"quoted value": {
|
||||||
|
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n",
|
||||||
|
want: "Ubuntu 24.04.4 LTS",
|
||||||
|
},
|
||||||
|
"unquoted value": {
|
||||||
|
osRelease: "PRETTY_NAME=Alpine Linux v3.21\n",
|
||||||
|
want: "Alpine Linux v3.21",
|
||||||
|
},
|
||||||
|
"no pretty name": {
|
||||||
|
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
// A key that merely ends in PRETTY_NAME must not be mistaken for it.
|
||||||
|
"similar key": {
|
||||||
|
osRelease: "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tt := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "os-release")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
|
||||||
|
|
||||||
|
original := osReleasePath
|
||||||
|
osReleasePath = path
|
||||||
|
defer func() { osReleasePath = original }()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, prettyOSName())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
original := osReleasePath
|
||||||
|
osReleasePath = filepath.Join(t.TempDir(), "absent")
|
||||||
|
defer func() { osReleasePath = original }()
|
||||||
|
|
||||||
|
assert.Empty(t, prettyOSName())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
||||||
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
||||||
@@ -36,6 +38,7 @@ func New(endpoint string, insecure bool, uuid, token string, opts ...connect.Cli
|
|||||||
|
|
||||||
opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
|
opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
|
||||||
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
|
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
|
||||||
|
req.Header().Set("User-Agent", "gitea-runner/"+ver.Version())
|
||||||
if uuid != "" {
|
if uuid != "" {
|
||||||
req.Header().Set(UUIDHeader, uuid)
|
req.Header().Set(UUIDHeader, uuid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ runner:
|
|||||||
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
|
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
|
||||||
# Set to false to clone the full history.
|
# Set to false to clone the full history.
|
||||||
action_shallow_clone: true
|
action_shallow_clone: true
|
||||||
|
# When true (the default), inject the ACT=true environment variable into jobs.
|
||||||
|
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
|
||||||
|
set_act_env: true
|
||||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||||
@@ -110,6 +113,11 @@ cache:
|
|||||||
dir: ""
|
dir: ""
|
||||||
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
# 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.
|
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
||||||
|
# If the runner itself runs in Docker, automatic detection can choose an
|
||||||
|
# address on the runner container's network that job containers cannot reach
|
||||||
|
# when the runner creates a separate per-job network. In that case, set this
|
||||||
|
# to a hostname/IP reachable from job containers, and set port to a fixed
|
||||||
|
# published port or put the job containers on a shared Docker network.
|
||||||
# Ignored when external_server is set.
|
# Ignored when external_server is set.
|
||||||
host: ""
|
host: ""
|
||||||
# Port for the built-in cache server. 0 picks a random free port.
|
# Port for the built-in cache server. 0 picks a random free port.
|
||||||
@@ -133,6 +141,8 @@ container:
|
|||||||
# Specifies the network to which the container will connect.
|
# Specifies the network to which the container will connect.
|
||||||
# Could be host, bridge or the name of a custom network.
|
# Could be host, bridge or the name of a custom network.
|
||||||
# If it's empty, runner will create a network automatically.
|
# If it's empty, runner will create a network automatically.
|
||||||
|
# For dockerized runners using the built-in cache server, a custom shared
|
||||||
|
# network can be required so job containers can reach cache.host/cache.port.
|
||||||
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
||||||
network: ""
|
network: ""
|
||||||
# network_create_options only apply when `network` is left empty and the runner
|
# network_create_options only apply when `network` is left empty and the runner
|
||||||
@@ -168,8 +178,9 @@ container:
|
|||||||
# If it's "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
# If it's "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||||
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
||||||
docker_host: ""
|
docker_host: ""
|
||||||
# Pull docker image(s) even if already present
|
# Pull docker image(s) even if already present.
|
||||||
force_pull: true
|
# Defaults to false when the key is omitted.
|
||||||
|
force_pull: false
|
||||||
# Rebuild docker image(s) even if already present
|
# Rebuild docker image(s) even if already present
|
||||||
force_rebuild: false
|
force_rebuild: false
|
||||||
# Always require a reachable docker daemon, even if not required by runner
|
# Always require a reachable docker daemon, even if not required by runner
|
||||||
@@ -188,11 +199,29 @@ host:
|
|||||||
# If it's empty, $HOME/.cache/act/ will be used.
|
# If it's empty, $HOME/.cache/act/ will be used.
|
||||||
workdir_parent:
|
workdir_parent:
|
||||||
|
|
||||||
|
# Optional local task-admission checks. Disabled by default. When enabled, low
|
||||||
|
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
||||||
|
# No health checks run while any job is active; the last result is reused until idle.
|
||||||
|
health_check:
|
||||||
|
enabled: false
|
||||||
|
# Minimum free space required on the filesystem holding runner workspaces.
|
||||||
|
# Defaults to 1024 MiB when omitted or set to zero.
|
||||||
|
min_free_disk_space_mb: 1024
|
||||||
|
# Optional additional executable. A non-zero exit, timeout, or startup failure
|
||||||
|
# marks the runner unavailable.
|
||||||
|
script: ''
|
||||||
|
# How long a script result is cached and its maximum execution time.
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
|
||||||
metrics:
|
metrics:
|
||||||
# Enable the Prometheus metrics endpoint.
|
# Enable the Prometheus metrics endpoint.
|
||||||
# When enabled, metrics are served at http://<addr>/metrics and a liveness check at /healthz.
|
# When enabled, metrics are served at /metrics, liveness at /healthz, and
|
||||||
|
# task-admission readiness at /readyz.
|
||||||
enabled: false
|
enabled: false
|
||||||
# The address for the metrics HTTP server to listen on.
|
# The address for the metrics HTTP server to listen on.
|
||||||
# Defaults to localhost only. Set to ":9101" to allow external access,
|
# Defaults to localhost only. Set to ":9101" to allow external access,
|
||||||
# but ensure the port is firewall-protected as there is no authentication.
|
# but ensure the port is firewall-protected as there is no authentication.
|
||||||
addr: "127.0.0.1:9101"
|
addr: "127.0.0.1:9101"
|
||||||
|
# Consecutive polling failures may last this long before /readyz returns 503.
|
||||||
|
readiness_grace: 30s
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type Runner struct {
|
|||||||
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
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
|
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
||||||
ActionShallowClone *bool `yaml:"action_shallow_clone"` // ActionShallowClone fetches only the requested ref of an action repository at depth 1 instead of cloning every branch's full history. It is a pointer to distinguish between false and not set; if not set, it defaults to true.
|
ActionShallowClone *bool `yaml:"action_shallow_clone"` // ActionShallowClone fetches only the requested ref of an action repository at depth 1 instead of cloning every branch's full history. It is a pointer to distinguish between false and not set; if not set, it defaults to true.
|
||||||
|
SetActEnv *bool `yaml:"set_act_env"` // SetActEnv controls whether the ACT=true environment variable is injected into jobs. It is a pointer to distinguish between false and not set; if not set, it defaults to true. Set it to false so workflows gated on `if: ${{ !env.ACT }}` behave like on 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.
|
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.
|
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.
|
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.
|
||||||
@@ -96,6 +97,17 @@ type Host struct {
|
|||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
|
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
|
||||||
Addr string `yaml:"addr"` // Addr specifies the listen address for the metrics HTTP server (e.g., ":9101").
|
Addr string `yaml:"addr"` // Addr specifies the listen address for the metrics HTTP server (e.g., ":9101").
|
||||||
|
ReadinessGrace time.Duration `yaml:"readiness_grace"` // ReadinessGrace permits transient polling errors before /readyz becomes unhealthy.
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck represents local checks that control whether the runner accepts
|
||||||
|
// new tasks. The entire feature is opt-in through Enabled.
|
||||||
|
type HealthCheck struct {
|
||||||
|
Enabled bool `yaml:"enabled"` // Enabled activates local task-admission health checks.
|
||||||
|
MinFreeDiskSpaceMB int64 `yaml:"min_free_disk_space_mb"` // MinFreeDiskSpaceMB is the minimum free space required on the work volume.
|
||||||
|
Script string `yaml:"script"` // Script is an optional executable used as an additional health check.
|
||||||
|
Interval time.Duration `yaml:"interval"` // Interval controls how long a script result is cached.
|
||||||
|
Timeout time.Duration `yaml:"timeout"` // Timeout caps one health-check script invocation.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config represents the overall configuration.
|
// Config represents the overall configuration.
|
||||||
@@ -106,6 +118,7 @@ type Config struct {
|
|||||||
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
||||||
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
||||||
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
|
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||||
|
HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks.
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadDefault returns the default configuration.
|
// LoadDefault returns the default configuration.
|
||||||
@@ -156,13 +169,20 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
b := true
|
b := true
|
||||||
cfg.Runner.ActionShallowClone = &b
|
cfg.Runner.ActionShallowClone = &b
|
||||||
}
|
}
|
||||||
|
if cfg.Runner.SetActEnv == nil {
|
||||||
|
b := true
|
||||||
|
cfg.Runner.SetActEnv = &b
|
||||||
|
}
|
||||||
if cfg.Cache.Enabled == nil {
|
if cfg.Cache.Enabled == nil {
|
||||||
b := true
|
b := true
|
||||||
cfg.Cache.Enabled = &b
|
cfg.Cache.Enabled = &b
|
||||||
}
|
}
|
||||||
if *cfg.Cache.Enabled {
|
if *cfg.Cache.Enabled {
|
||||||
if cfg.Cache.Dir == "" {
|
if cfg.Cache.Dir == "" {
|
||||||
home, _ := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cache.dir is unset and the user home directory could not be determined: %w", err)
|
||||||
|
}
|
||||||
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
||||||
}
|
}
|
||||||
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
||||||
@@ -173,7 +193,10 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
cfg.Container.WorkdirParent = "workspace"
|
cfg.Container.WorkdirParent = "workspace"
|
||||||
}
|
}
|
||||||
if cfg.Host.WorkdirParent == "" {
|
if cfg.Host.WorkdirParent == "" {
|
||||||
home, _ := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("host.workdir_parent is unset and the user home directory could not be determined: %w", err)
|
||||||
|
}
|
||||||
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
|
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
|
||||||
}
|
}
|
||||||
if cfg.Runner.FetchTimeout <= 0 {
|
if cfg.Runner.FetchTimeout <= 0 {
|
||||||
@@ -209,9 +232,21 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if cfg.Runner.PostTaskScript != "" && cfg.Runner.PostTaskScriptTimeout <= 0 {
|
if cfg.Runner.PostTaskScript != "" && cfg.Runner.PostTaskScriptTimeout <= 0 {
|
||||||
cfg.Runner.PostTaskScriptTimeout = DefaultPostTaskScriptTimeout
|
cfg.Runner.PostTaskScriptTimeout = DefaultPostTaskScriptTimeout
|
||||||
}
|
}
|
||||||
|
if cfg.HealthCheck.MinFreeDiskSpaceMB <= 0 {
|
||||||
|
cfg.HealthCheck.MinFreeDiskSpaceMB = 1024
|
||||||
|
}
|
||||||
|
if cfg.HealthCheck.Interval <= 0 {
|
||||||
|
cfg.HealthCheck.Interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.HealthCheck.Timeout <= 0 {
|
||||||
|
cfg.HealthCheck.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
if cfg.Metrics.Addr == "" {
|
if cfg.Metrics.Addr == "" {
|
||||||
cfg.Metrics.Addr = "127.0.0.1:9101"
|
cfg.Metrics.Addr = "127.0.0.1:9101"
|
||||||
}
|
}
|
||||||
|
if cfg.Metrics.ReadinessGrace <= 0 {
|
||||||
|
cfg.Metrics.ReadinessGrace = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
// Validate and fix invalid config combinations to prevent confusing behavior.
|
// Validate and fix invalid config combinations to prevent confusing behavior.
|
||||||
if cfg.Runner.FetchIntervalMax < cfg.Runner.FetchInterval {
|
if cfg.Runner.FetchIntervalMax < cfg.Runner.FetchInterval {
|
||||||
|
|||||||
@@ -48,6 +48,41 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
|
|||||||
assert.Equal(t, 10*time.Minute, cfg.Runner.IdleCleanupInterval)
|
assert.Equal(t, 10*time.Minute, cfg.Runner.IdleCleanupInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
|
||||||
|
cfg, err := LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, cfg.HealthCheck.Enabled)
|
||||||
|
assert.Equal(t, int64(1024), cfg.HealthCheck.MinFreeDiskSpaceMB)
|
||||||
|
assert.Empty(t, cfg.HealthCheck.Script)
|
||||||
|
assert.Equal(t, 30*time.Second, cfg.HealthCheck.Interval)
|
||||||
|
assert.Equal(t, 10*time.Second, cfg.HealthCheck.Timeout)
|
||||||
|
assert.False(t, cfg.Metrics.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_DiskAndReadinessSettings(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
health_check:
|
||||||
|
enabled: true
|
||||||
|
min_free_disk_space_mb: 4096
|
||||||
|
script: /usr/local/bin/runner-health
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
metrics:
|
||||||
|
readiness_grace: 45s
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, cfg.HealthCheck.Enabled)
|
||||||
|
assert.Equal(t, int64(4096), cfg.HealthCheck.MinFreeDiskSpaceMB)
|
||||||
|
assert.Equal(t, "/usr/local/bin/runner-health", cfg.HealthCheck.Script)
|
||||||
|
assert.Equal(t, 15*time.Second, cfg.HealthCheck.Interval)
|
||||||
|
assert.Equal(t, 3*time.Second, cfg.HealthCheck.Timeout)
|
||||||
|
assert.Equal(t, 45*time.Second, cfg.Metrics.ReadinessGrace)
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDefault_UsesConfiguredWorkdirCleanupAge(t *testing.T) {
|
func TestLoadDefault_UsesConfiguredWorkdirCleanupAge(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
package labels
|
package labels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,13 +17,20 @@ type Label struct {
|
|||||||
Name string
|
Name string
|
||||||
Schema string
|
Schema string
|
||||||
Arg string
|
Arg string
|
||||||
|
// Opaque marks a label whose name contains a colon but no supported schema,
|
||||||
|
// like "pool:e57e18d4-...". It is kept verbatim and behaves like a host label.
|
||||||
|
Opaque bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse(str string) (*Label, error) {
|
func Parse(str string) (*Label, error) {
|
||||||
|
if str == "" {
|
||||||
|
return nil, errors.New("empty label")
|
||||||
|
}
|
||||||
|
|
||||||
splits := strings.SplitN(str, ":", 3)
|
splits := strings.SplitN(str, ":", 3)
|
||||||
label := &Label{
|
label := &Label{
|
||||||
Name: splits[0],
|
Name: splits[0],
|
||||||
Schema: "host",
|
Schema: SchemeHost,
|
||||||
Arg: "",
|
Arg: "",
|
||||||
}
|
}
|
||||||
if len(splits) >= 2 {
|
if len(splits) >= 2 {
|
||||||
@@ -33,7 +40,12 @@ func Parse(str string) (*Label, error) {
|
|||||||
label.Arg = splits[2]
|
label.Arg = splits[2]
|
||||||
}
|
}
|
||||||
if label.Schema != SchemeHost && label.Schema != SchemeDocker {
|
if label.Schema != SchemeHost && label.Schema != SchemeDocker {
|
||||||
return nil, fmt.Errorf("unsupported schema: %s", label.Schema)
|
// Not a schema we know: the colon belongs to the label name itself.
|
||||||
|
return &Label{
|
||||||
|
Name: str,
|
||||||
|
Schema: SchemeHost,
|
||||||
|
Opaque: true,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
return label, nil
|
return label, nil
|
||||||
}
|
}
|
||||||
@@ -59,7 +71,7 @@ func (l Labels) PickPlatform(runsOn []string) string {
|
|||||||
case SchemeHost:
|
case SchemeHost:
|
||||||
platforms[label.Name] = "-self-hosted"
|
platforms[label.Name] = "-self-hosted"
|
||||||
default:
|
default:
|
||||||
// It should not happen, because Parse has checked it.
|
// unreachable: Parse only produces host or docker schemas
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +106,7 @@ func (l Labels) ToStrings() []string {
|
|||||||
ls := make([]string, 0, len(l))
|
ls := make([]string, 0, len(l))
|
||||||
for _, label := range l {
|
for _, label := range l {
|
||||||
lbl := label.Name
|
lbl := label.Name
|
||||||
if label.Schema != "" {
|
if !label.Opaque && label.Schema != "" {
|
||||||
lbl += ":" + label.Schema
|
lbl += ":" + label.Schema
|
||||||
if label.Arg != "" {
|
if label.Arg != "" {
|
||||||
lbl += ":" + label.Arg
|
lbl += ":" + label.Arg
|
||||||
|
|||||||
@@ -43,8 +43,28 @@ func TestParse(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
args: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd",
|
||||||
|
want: &Label{
|
||||||
|
Name: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd",
|
||||||
|
Schema: "host",
|
||||||
|
Arg: "",
|
||||||
|
Opaque: true,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
args: "ubuntu:vm:ubuntu-18.04",
|
args: "ubuntu:vm:ubuntu-18.04",
|
||||||
|
want: &Label{
|
||||||
|
Name: "ubuntu:vm:ubuntu-18.04",
|
||||||
|
Schema: "host",
|
||||||
|
Arg: "",
|
||||||
|
Opaque: true,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: "",
|
||||||
want: nil,
|
want: nil,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@@ -116,8 +136,8 @@ func TestPickPlatform(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNames(t *testing.T) {
|
func TestNames(t *testing.T) {
|
||||||
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host")
|
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host", "pool:e57e18d4")
|
||||||
require.Equal(t, []string{"ubuntu", "self-hosted"}, ls.Names())
|
require.Equal(t, []string{"ubuntu", "self-hosted", "pool:e57e18d4"}, ls.Names())
|
||||||
require.Empty(t, Labels{}.Names())
|
require.Empty(t, Labels{}.Names())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,10 +146,26 @@ func TestToStrings(t *testing.T) {
|
|||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
"bare",
|
"bare",
|
||||||
|
"pool:e57e18d4",
|
||||||
)
|
)
|
||||||
require.Equal(t, []string{
|
require.Equal(t, []string{
|
||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
"bare:host",
|
"bare:host",
|
||||||
|
"pool:e57e18d4",
|
||||||
}, ls.ToStrings())
|
}, ls.ToStrings())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a colon-containing name must survive a write to and read back from the .runner file
|
||||||
|
func TestOpaqueLabelRoundTrip(t *testing.T) {
|
||||||
|
const raw = "pool:e57e18d4-10d4-406f-93bf-60f127221bdd"
|
||||||
|
|
||||||
|
ls := mustParse(t, raw)
|
||||||
|
require.Equal(t, []string{raw}, ls.ToStrings())
|
||||||
|
|
||||||
|
again := mustParse(t, ls.ToStrings()...)
|
||||||
|
require.Equal(t, ls, again)
|
||||||
|
require.Equal(t, []string{raw}, again.Names())
|
||||||
|
require.False(t, again.RequireDocker())
|
||||||
|
require.Equal(t, "-self-hosted", again.PickPlatform([]string{raw}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestRegisterRunningJobsFuncZeroCapacity(t *testing.T) {
|
|||||||
|
|
||||||
func TestStartServerCanBeCancelled(t *testing.T) {
|
func TestStartServerCanBeCancelled(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
StartServer(ctx, "127.0.0.1:0")
|
StartServer(ctx, "127.0.0.1:0", nil)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,19 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// StartServer starts an HTTP server that serves Prometheus metrics on /metrics
|
// StartServer starts an HTTP server that serves Prometheus metrics on /metrics
|
||||||
// and a liveness check on /healthz. The server shuts down when ctx is cancelled.
|
// and health checks. The server shuts down when ctx is cancelled.
|
||||||
// Call Init() before StartServer to register metrics with the Registry.
|
// Call Init() before StartServer to register metrics with the Registry.
|
||||||
func StartServer(ctx context.Context, addr string) {
|
func StartServer(ctx context.Context, addr string, readiness func() (bool, string)) {
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: NewHTTPHandler(readiness),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
@@ -48,3 +41,25 @@ func StartServer(ctx context.Context, addr string) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewHTTPHandler returns the metrics and health endpoints used by StartServer.
|
||||||
|
func NewHTTPHandler(readiness func() (bool, string)) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
ready, reason := true, "ok"
|
||||||
|
if readiness != nil {
|
||||||
|
ready, reason = readiness()
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(reason))
|
||||||
|
})
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|||||||
43
internal/pkg/metrics/server_test.go
Normal file
43
internal/pkg/metrics/server_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadyEndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ready bool
|
||||||
|
reason string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"ready", true, "ok", http.StatusOK},
|
||||||
|
{"not ready", false, "low disk space", http.StatusServiceUnavailable},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
handler := NewHTTPHandler(func() (bool, string) { return test.ready, test.reason })
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
assert.Equal(t, test.status, response.Code)
|
||||||
|
assert.Equal(t, test.reason, response.Body.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthEndpointStaysLiveWhenNotReady(t *testing.T) {
|
||||||
|
handler := NewHTTPHandler(func() (bool, string) { return false, "low disk space" })
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
assert.Equal(t, http.StatusOK, response.Code)
|
||||||
|
assert.Equal(t, "ok", response.Body.String())
|
||||||
|
}
|
||||||
28
internal/pkg/process/group_others.go
Normal file
28
internal/pkg/process/group_others.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// Group is a no-op outside Windows: process groups already reclaim a step's
|
||||||
|
// tree, and an orphan does not block workspace deletion the way an open Windows
|
||||||
|
// handle does.
|
||||||
|
type Group struct{}
|
||||||
|
|
||||||
|
// NewGroup returns a Group that owns nothing.
|
||||||
|
func NewGroup() (*Group, error) {
|
||||||
|
return &Group{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign does nothing.
|
||||||
|
func (g *Group) Assign(_ *os.Process) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close does nothing.
|
||||||
|
func (g *Group) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
86
internal/pkg/process/group_windows.go
Normal file
86
internal/pkg/process/group_windows.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group is a job-scoped Windows Job Object holding every process the job's steps
|
||||||
|
// start; closing it terminates whatever is still assigned, reaching orphans the
|
||||||
|
// per-step Killer misses (a completed step's leftover has no parent to walk from).
|
||||||
|
type Group struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
job windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroup creates the job object. Closing it is what terminates the leftovers,
|
||||||
|
// so the caller must Close it when the job ends.
|
||||||
|
func NewGroup() (*Group, error) {
|
||||||
|
job, err := windows.CreateJobObject(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||||
|
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||||
|
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := windows.SetInformationJobObject(
|
||||||
|
job,
|
||||||
|
windows.JobObjectExtendedLimitInformation,
|
||||||
|
uintptr(unsafe.Pointer(&info)),
|
||||||
|
uint32(unsafe.Sizeof(info)),
|
||||||
|
); err != nil {
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Group{job: job}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign adds a started process to the job; anything it spawns afterwards joins
|
||||||
|
// too. Call before NewKiller so the step's job nests inside this one. Nil-safe.
|
||||||
|
func (g *Group) Assign(p *os.Process) error {
|
||||||
|
if g == nil || p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if g.job == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = windows.CloseHandle(h) }()
|
||||||
|
|
||||||
|
return windows.AssignProcessToJobObject(g.job, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drops the job handle, terminating every process still assigned to it.
|
||||||
|
// Nil-safe and idempotent.
|
||||||
|
func (g *Group) Close() error {
|
||||||
|
if g == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if g.job == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h := g.job
|
||||||
|
g.job = 0
|
||||||
|
return windows.CloseHandle(h)
|
||||||
|
}
|
||||||
113
internal/pkg/process/group_windows_test.go
Normal file
113
internal/pkg/process/group_windows_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startOrphanMaker starts a process that spawns a detached, long-lived child and
|
||||||
|
// exits, leaving the child parentless. Returns the cmd and the child's PID file.
|
||||||
|
func startOrphanMaker(t *testing.T) (*exec.Cmd, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
||||||
|
script := fmt.Sprintf(
|
||||||
|
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
|
||||||
|
`Set-Content -LiteralPath %q -Value $c.Id`, pidFile)
|
||||||
|
|
||||||
|
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||||
|
|
||||||
|
return cmd, pidFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitChildPID waits until the spawned child has reported its PID and is running.
|
||||||
|
func awaitChildPID(t *testing.T, pidFile string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var childPID int
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
b, err := os.ReadFile(pidFile)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(b))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
childPID, _ = strconv.Atoi(s)
|
||||||
|
return childPID > 0 && processAlive(childPID)
|
||||||
|
}, 20*time.Second, 200*time.Millisecond, "child process should start")
|
||||||
|
|
||||||
|
return childPID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGroupClosePropagatesToOrphan covers what the per-step Killer cannot: a
|
||||||
|
// completed step's orphan, reachable only by closing the job object.
|
||||||
|
func TestGroupClosePropagatesToOrphan(t *testing.T) {
|
||||||
|
group, err := NewGroup()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = group.Close() })
|
||||||
|
|
||||||
|
cmd, pidFile := startOrphanMaker(t)
|
||||||
|
require.NoError(t, group.Assign(cmd.Process))
|
||||||
|
|
||||||
|
childPID := awaitChildPID(t, pidFile)
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Wait()) // the step process exits cleanly, like a passing step
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !processAlive(cmd.Process.Pid)
|
||||||
|
}, 20*time.Second, 200*time.Millisecond, "the step process should have exited on its own")
|
||||||
|
|
||||||
|
require.True(t, processAlive(childPID), "orphan should outlive its parent")
|
||||||
|
|
||||||
|
require.NoError(t, group.Close())
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !processAlive(childPID)
|
||||||
|
}, 20*time.Second, 200*time.Millisecond, "closing the job must terminate the orphan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGroupNestsWithKiller: assigned to the group first and the step's Killer
|
||||||
|
// second, cancelling the step still kills exactly that step's tree.
|
||||||
|
func TestGroupNestsWithKiller(t *testing.T) {
|
||||||
|
group, err := NewGroup()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = group.Close() })
|
||||||
|
|
||||||
|
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Sleep -Seconds 600")
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||||
|
|
||||||
|
require.NoError(t, group.Assign(cmd.Process))
|
||||||
|
|
||||||
|
// Nesting must be accepted, else cancellation falls back to a single-process kill.
|
||||||
|
killer, err := NewKiller(cmd.Process)
|
||||||
|
require.NoError(t, err, "the step's job object must nest inside the group's")
|
||||||
|
defer killer.Close()
|
||||||
|
|
||||||
|
require.NoError(t, killer.Kill())
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !processAlive(cmd.Process.Pid)
|
||||||
|
}, 20*time.Second, 200*time.Millisecond, "cancelling the step should still kill its tree")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGroupNilSafe covers the fallback path: when the job object cannot be
|
||||||
|
// created the caller holds a nil *Group and must still be able to use it.
|
||||||
|
func TestGroupNilSafe(t *testing.T) {
|
||||||
|
var group *Group
|
||||||
|
require.NoError(t, group.Assign(nil))
|
||||||
|
require.NoError(t, group.Close())
|
||||||
|
require.NoError(t, group.Close())
|
||||||
|
}
|
||||||
29
internal/pkg/process/leftover.go
Normal file
29
internal/pkg/process/leftover.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pathWithin is the OS-agnostic core of dirContainsPath: dir and target must
|
||||||
|
// already be cleaned, and fold folds case.
|
||||||
|
func pathWithin(dir, target string, fold bool) bool {
|
||||||
|
if dir == "" || target == "" || dir == "." || target == "." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fold {
|
||||||
|
dir = strings.ToLower(dir)
|
||||||
|
target = strings.ToLower(target)
|
||||||
|
}
|
||||||
|
if dir == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sep := string(filepath.Separator)
|
||||||
|
if !strings.HasSuffix(dir, sep) {
|
||||||
|
dir += sep
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(target, dir)
|
||||||
|
}
|
||||||
14
internal/pkg/process/leftover_others.go
Normal file
14
internal/pkg/process/leftover_others.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// KillProcessesWithCWDUnder is a no-op outside Windows: an open handle does not
|
||||||
|
// block os.RemoveAll there.
|
||||||
|
func KillProcessesWithCWDUnder(_ context.Context, _ []string) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
41
internal/pkg/process/leftover_test.go
Normal file
41
internal/pkg/process/leftover_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPathWithin(t *testing.T) {
|
||||||
|
base := filepath.Join("base", "job1")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dir string
|
||||||
|
target string
|
||||||
|
fold bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"same dir", base, base, false, true},
|
||||||
|
{"direct child", base, filepath.Join(base, "app.exe"), false, true},
|
||||||
|
{"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), false, true},
|
||||||
|
{"parent is not within child", filepath.Join(base, "sub"), base, false, false},
|
||||||
|
{"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false, false},
|
||||||
|
{"unrelated", base, filepath.Join("other", "app.exe"), false, false},
|
||||||
|
{"empty dir", "", base, false, false},
|
||||||
|
{"empty target", base, "", false, false},
|
||||||
|
{"dot dir", ".", base, false, false},
|
||||||
|
{"case-sensitive miss", base, filepath.Join("base", "JOB1", "app.exe"), false, false},
|
||||||
|
{"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := pathWithin(filepath.Clean(tc.dir), filepath.Clean(tc.target), tc.fold)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("pathWithin(%q, %q, fold=%v) = %v, want %v", tc.dir, tc.target, tc.fold, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
182
internal/pkg/process/leftover_windows.go
Normal file
182
internal/pkg/process/leftover_windows.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dirContainsPath reports whether target is dir or lives beneath it,
|
||||||
|
// case-insensitively, so a name-prefix sibling (job1 vs job10) is not a match.
|
||||||
|
func dirContainsPath(dir, target string) bool {
|
||||||
|
return pathWithin(filepath.Clean(dir), filepath.Clean(target), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillProcessesWithCWDUnder terminates every process in the runner's session
|
||||||
|
// whose cwd is one of dirs or below, catching leftovers the path scan misses
|
||||||
|
// (Win32_Process exposes no cwd) that still pin a handle. Best-effort.
|
||||||
|
func KillProcessesWithCWDUnder(ctx context.Context, dirs []string) (int, error) {
|
||||||
|
if len(dirs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pids, err := listProcessIDs()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
self := uint32(os.Getpid())
|
||||||
|
var ownSession uint32
|
||||||
|
if err := windows.ProcessIdToSessionId(self, &ownSession); err != nil {
|
||||||
|
return 0, fmt.Errorf("look up own session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var killed int
|
||||||
|
var errs []error
|
||||||
|
for _, pid := range pids {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Never touch ourselves, System Idle (0) or System (4).
|
||||||
|
if pid == self || pid == 0 || pid == 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var session uint32
|
||||||
|
if err := windows.ProcessIdToSessionId(pid, &session); err != nil || session != ownSession {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := terminateIfCWDUnder(pid, dirs)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
killed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return killed, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminateIfCWDUnder opens pid once and both reads its cwd and terminates it
|
||||||
|
// through that one handle, so a PID recycled between the two cannot be hit.
|
||||||
|
func terminateIfCWDUnder(pid uint32, dirs []string) (bool, error) {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ|windows.PROCESS_TERMINATE, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil // best-effort: gone, higher integrity, or another user
|
||||||
|
}
|
||||||
|
defer func() { _ = windows.CloseHandle(h) }()
|
||||||
|
|
||||||
|
cwd, err := processCWDHandle(h)
|
||||||
|
if err != nil || cwd == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range dirs {
|
||||||
|
if dirContainsPath(d, cwd) {
|
||||||
|
if err := windows.TerminateProcess(h, 1); err != nil {
|
||||||
|
return false, fmt.Errorf("terminate pid %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listProcessIDs returns the PIDs of every process in a Toolhelp snapshot.
|
||||||
|
func listProcessIDs() ([]uint32, error) {
|
||||||
|
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = windows.CloseHandle(snap) }()
|
||||||
|
|
||||||
|
var entry windows.ProcessEntry32
|
||||||
|
entry.Size = uint32(unsafe.Sizeof(entry))
|
||||||
|
|
||||||
|
pids := make([]uint32, 0, 256)
|
||||||
|
err = windows.Process32First(snap, &entry)
|
||||||
|
for err == nil {
|
||||||
|
pids = append(pids, entry.ProcessID)
|
||||||
|
err = windows.Process32Next(snap, &entry)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||||
|
return pids, err
|
||||||
|
}
|
||||||
|
return pids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processCWDHandle reads a process's working directory from its PEB (walking
|
||||||
|
// PEB -> ProcessParameters -> CurrentDirectory.DosPath). h must carry
|
||||||
|
// PROCESS_QUERY_INFORMATION|PROCESS_VM_READ.
|
||||||
|
func processCWDHandle(h windows.Handle) (string, error) {
|
||||||
|
var pbi windows.PROCESS_BASIC_INFORMATION
|
||||||
|
var retLen uint32
|
||||||
|
if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if pbi.PebBaseAddress == nil {
|
||||||
|
return "", errors.New("nil PEB base address")
|
||||||
|
}
|
||||||
|
pebAddr := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
|
||||||
|
|
||||||
|
paramsAddr, err := readRemotePtr(h, pebAddr+unsafe.Offsetof(windows.PEB{}.ProcessParameters))
|
||||||
|
if err != nil || paramsAddr == 0 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DosPath is the leading NTUnicodeString of CURDIR, so it shares CurrentDirectory's address.
|
||||||
|
dosPathAddr := paramsAddr + unsafe.Offsetof(windows.RTL_USER_PROCESS_PARAMETERS{}.CurrentDirectory)
|
||||||
|
length, err := readRemoteU16(h, dosPathAddr+unsafe.Offsetof(windows.NTUnicodeString{}.Length))
|
||||||
|
if err != nil || length == 0 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
bufAddr, err := readRemotePtr(h, dosPathAddr+unsafe.Offsetof(windows.NTUnicodeString{}.Buffer))
|
||||||
|
if err != nil || bufAddr == 0 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
u16 := make([]uint16, (length+1)/2) // round up so an odd Length cannot overflow the buffer
|
||||||
|
var n uintptr
|
||||||
|
if err := windows.ReadProcessMemory(h, bufAddr, (*byte)(unsafe.Pointer(&u16[0])), uintptr(length), &n); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n != uintptr(length) {
|
||||||
|
return "", errors.New("short read of working directory")
|
||||||
|
}
|
||||||
|
return windows.UTF16ToString(u16), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRemotePtr reads a single pointer-sized value from another process.
|
||||||
|
func readRemotePtr(h windows.Handle, addr uintptr) (uintptr, error) {
|
||||||
|
var v uintptr
|
||||||
|
var n uintptr
|
||||||
|
if err := windows.ReadProcessMemory(h, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n != unsafe.Sizeof(v) {
|
||||||
|
return 0, errors.New("short pointer read")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRemoteU16 reads a single uint16 from another process.
|
||||||
|
func readRemoteU16(h windows.Handle, addr uintptr) (uint16, error) {
|
||||||
|
var v uint16
|
||||||
|
var n uintptr
|
||||||
|
if err := windows.ReadProcessMemory(h, addr, (*byte)(unsafe.Pointer(&v)), unsafe.Sizeof(v), &n); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n != unsafe.Sizeof(v) {
|
||||||
|
return 0, errors.New("short uint16 read")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
91
internal/pkg/process/leftover_windows_test.go
Normal file
91
internal/pkg/process/leftover_windows_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startSleeperIn launches a long-running child whose cwd is dir but whose
|
||||||
|
// executable lives outside it — what the path scan cannot see.
|
||||||
|
func startSleeperIn(t *testing.T, dir string) *exec.Cmd {
|
||||||
|
t.Helper()
|
||||||
|
ping := filepath.Join(os.Getenv("SystemRoot"), "System32", "ping.exe")
|
||||||
|
cmd := exec.Command(ping, "-n", "60", "127.0.0.1")
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_, _ = cmd.Process.Wait()
|
||||||
|
})
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// processCWD opens pid and reads its working directory. Test-only; the reaper
|
||||||
|
// shares one handle across read and kill via processCWDHandle.
|
||||||
|
func processCWD(pid uint32) (string, error) {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = windows.CloseHandle(h) }()
|
||||||
|
return processCWDHandle(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitCWD waits until pid's PEB reports a readable cwd (not populated the
|
||||||
|
// instant the process starts) and returns it.
|
||||||
|
func awaitCWD(t *testing.T, pid int) string {
|
||||||
|
t.Helper()
|
||||||
|
var cwd string
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
var err error
|
||||||
|
cwd, err = processCWD(uint32(pid))
|
||||||
|
return err == nil && cwd != ""
|
||||||
|
}, 5*time.Second, 50*time.Millisecond, "process cwd should become readable")
|
||||||
|
return cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCWDReadsWorkingDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmd := startSleeperIn(t, dir)
|
||||||
|
cwd := awaitCWD(t, cmd.Process.Pid)
|
||||||
|
require.True(t, dirContainsPath(dir, cwd), "cwd %q should be within %q", cwd, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillProcessesWithCWDUnderTerminatesMatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Executable outside dir and arguments carry no workspace path, so only the
|
||||||
|
// working directory links this process to dir.
|
||||||
|
cmd := startSleeperIn(t, dir)
|
||||||
|
awaitCWD(t, cmd.Process.Pid)
|
||||||
|
|
||||||
|
killed, err := KillProcessesWithCWDUnder(context.Background(), []string{dir})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, killed, 1)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !processAlive(cmd.Process.Pid)
|
||||||
|
}, 5*time.Second, 50*time.Millisecond, "matched process should exit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKillProcessesWithCWDUnderSparesUnrelated(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
elsewhere := t.TempDir()
|
||||||
|
cmd := startSleeperIn(t, elsewhere)
|
||||||
|
awaitCWD(t, cmd.Process.Pid)
|
||||||
|
|
||||||
|
_, err := KillProcessesWithCWDUnder(context.Background(), []string{workspace})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, processAlive(cmd.Process.Pid), "process outside owned dirs must be spared")
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Size limits for the outputs reported to the server.
|
||||||
|
const (
|
||||||
|
maxOutputKeyLen = 255
|
||||||
|
maxOutputValueLen = 1024 * 1024 // 1 MiB
|
||||||
|
)
|
||||||
|
|
||||||
type Reporter struct {
|
type Reporter struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -390,13 +396,15 @@ func (r *Reporter) SetOutputs(outputs map[string]string) {
|
|||||||
defer r.stateMu.Unlock()
|
defer r.stateMu.Unlock()
|
||||||
|
|
||||||
for k, v := range outputs {
|
for k, v := range outputs {
|
||||||
if len(k) > 255 {
|
if l := len(k); l > maxOutputKeyLen {
|
||||||
r.logf("ignore output because the key is too long: %q", k)
|
log.Warnf("ignore output %q because the key is too long: %d > %d", k, l, maxOutputKeyLen)
|
||||||
|
r.logf("ignore output %q because the key is too long: %d > %d", k, l, maxOutputKeyLen)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if l := len(v); l > 1024*1024 {
|
if l := len(v); l > maxOutputValueLen {
|
||||||
log.Println("ignore output because the value is too long:", k, l)
|
log.Warnf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen)
|
||||||
r.logf("ignore output because the value %q is too long: %d", k, l)
|
r.logf("ignore output %q because the value is too long: %d > %d", k, l, maxOutputValueLen)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := r.outputs.Load(k); ok {
|
if _, ok := r.outputs.Load(k); ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1020,11 +1020,24 @@ func TestReporter_SetOutputs(t *testing.T) {
|
|||||||
got, _ = r.outputs.Load("foo")
|
got, _ = r.outputs.Load("foo")
|
||||||
assert.Equal(t, "bar", got)
|
assert.Equal(t, "bar", got)
|
||||||
|
|
||||||
// keys longer than 255 chars are dropped
|
// keys longer than maxOutputKeyLen are dropped
|
||||||
longKey := strings.Repeat("k", 256)
|
longKey := strings.Repeat("k", maxOutputKeyLen+1)
|
||||||
r.SetOutputs(map[string]string{longKey: "v"})
|
r.SetOutputs(map[string]string{longKey: "v"})
|
||||||
_, ok = r.outputs.Load(longKey)
|
_, ok = r.outputs.Load(longKey)
|
||||||
assert.False(t, ok)
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// values longer than maxOutputValueLen are dropped
|
||||||
|
longValue := strings.Repeat("v", maxOutputValueLen+1)
|
||||||
|
r.SetOutputs(map[string]string{"big": longValue})
|
||||||
|
_, ok = r.outputs.Load("big")
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// a value at exactly the limit is still stored
|
||||||
|
maxValue := strings.Repeat("v", maxOutputValueLen)
|
||||||
|
r.SetOutputs(map[string]string{"atlimit": maxValue})
|
||||||
|
got, ok = r.outputs.Load("atlimit")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, got, maxOutputValueLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_EffectiveCloseTimeout(t *testing.T) {
|
func TestReporter_EffectiveCloseTimeout(t *testing.T) {
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ CONFIG_ARG=""
|
|||||||
if [[ ! -z "${CONFIG_FILE}" ]]; then
|
if [[ ! -z "${CONFIG_FILE}" ]]; then
|
||||||
CONFIG_ARG="--config ${CONFIG_FILE}"
|
CONFIG_ARG="--config ${CONFIG_FILE}"
|
||||||
fi
|
fi
|
||||||
EXTRA_ARGS=""
|
LABEL_ARGS=""
|
||||||
if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then
|
if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then
|
||||||
EXTRA_ARGS="${EXTRA_ARGS} --labels ${GITEA_RUNNER_LABELS}"
|
LABEL_ARGS="--labels ${GITEA_RUNNER_LABELS}"
|
||||||
fi
|
fi
|
||||||
|
EXTRA_ARGS="${LABEL_ARGS}"
|
||||||
if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then
|
if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then
|
||||||
EXTRA_ARGS="${EXTRA_ARGS} --ephemeral"
|
EXTRA_ARGS="${EXTRA_ARGS} --ephemeral"
|
||||||
fi
|
fi
|
||||||
RUN_ARGS=""
|
# Also pass the labels to the daemon, so that an already registered runner
|
||||||
|
# picks up changes to GITEA_RUNNER_LABELS instead of keeping the labels it
|
||||||
|
# was first registered with.
|
||||||
|
RUN_ARGS="${LABEL_ARGS}"
|
||||||
if [[ ! -z "${GITEA_RUNNER_ONCE}" ]]; then
|
if [[ ! -z "${GITEA_RUNNER_ONCE}" ]]; then
|
||||||
RUN_ARGS="${RUN_ARGS} --once"
|
RUN_ARGS="${RUN_ARGS} --once"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user