Compare commits

..

5 Commits

Author SHA1 Message Date
Renovate Bot
ad967330a8 fix(deps): update module golang.org/x/text to v0.40.0 (#1091)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) | [`v0.37.0` → `v0.40.0`](https://cs.opensource.google/go/x/text/+/refs/tags/v0.37.0...refs/tags/v0.40.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2ftext/v0.40.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2ftext/v0.37.0/v0.40.0?slim=true) |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->Reviewed-on: https://gitea.com/gitea/runner/pulls/1091
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-16 09:22:33 +00:00
bircni
60177008a5 fix: ignore blank lines and decode UTF-16 in the runner env files (#1084)
Fixes #496
Fixes #552

Reviewed-on: https://gitea.com/gitea/runner/pulls/1084
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-07-15 19:45:50 +00:00
bircni
58c5eb8d21 feat: honour GITEA_RUNNER_LABELS on daemon start and accept labels containing a colon (#1085)
Fixes #648
Fixes #656
Fixes #664

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1085
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-15 16:58:07 +00:00
bircni
d6882b3df5 docs: explain labels, the docker image cache volume, and the dind-rootless UID (#1086)
Fixes #106
Fixes #570
Fixes #627

Reviewed-on: https://gitea.com/gitea/runner/pulls/1086
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-15 06:34:32 +00:00
bircni
7e7e3ef1a6 fix: stop service containers from clobbering the job container's credentials (#1083)
Fixes #835
Fixes #643

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1083
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-15 06:15:50 +00:00
22 changed files with 443 additions and 51 deletions

View File

@@ -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)).
> **`/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
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).
> **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.
### Configuration
@@ -147,10 +151,62 @@ If you omit `-c`, built-in defaults apply (same as an empty YAML document).
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 |
| --- | --- |
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
| `args` | Only used by the `docker` schema: the image to run the job in. |
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
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`)

View File

@@ -72,7 +72,9 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
_ = 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
}

View File

@@ -13,6 +13,9 @@ import (
"strings"
"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 {
@@ -28,11 +31,19 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
if err != nil && err != io.EOF {
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.
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for s.Scan() {
line := s.Text()
// GitHub's runner ignores blank lines
if strings.TrimSpace(line) == "" {
continue
}
singleLineEnv := strings.Index(line, "=")
multiLineEnv := strings.Index(line, "<<")
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {

View File

@@ -13,6 +13,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/unicode"
)
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
@@ -64,6 +66,63 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
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) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))

View File

@@ -229,7 +229,8 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
logger.Debugf("evaluating expression '%s'", in)
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)
return evaluated, err

View File

@@ -339,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 {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
@@ -402,7 +405,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
for _, v := range spec.Cmd {
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 {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
}
@@ -423,12 +428,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := container.NewContainer(&container.NewContainerInput{
c := newContainer(&container.NewContainerInput{
Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: serviceImage,
Username: username,
Password: password,
Username: serviceUsername,
Password: servicePassword,
Cmd: interpolatedCmd,
Env: envs,
Mounts: serviceMounts,
@@ -484,7 +489,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName
rc.JobContainer = container.NewContainer(&container.NewContainerInput{
rc.JobContainer = newContainer(&container.NewContainerInput{
Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),

View File

@@ -14,6 +14,7 @@ import (
"testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
@@ -202,6 +203,93 @@ jobs:
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) {
rctemplate := &RunContext{
Name: "TestRCName",

View File

@@ -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
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:
- [`dind-docker.yaml`](dind-docker.yaml)

View File

@@ -1,3 +1,5 @@
# Holds the runner's working directory (/data): the .runner registration file
# and, optionally, the config file.
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
@@ -10,6 +12,21 @@ spec:
storage: 1Gi
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
data:
# The registration token can be obtained from the web UI, API or command-line.
@@ -45,6 +62,9 @@ spec:
- name: runner-data
persistentVolumeClaim:
claimName: runner-vol
- name: docker-data
persistentVolumeClaim:
claimName: docker-vol
initContainers:
- name: docker
image: docker:28.2.2-dind
@@ -53,6 +73,8 @@ spec:
volumeMounts:
- name: docker-socket
mountPath: /var/run
- name: docker-data
mountPath: /var/lib/docker
startupProbe:
exec:
command: ["/usr/bin/test", "-S", "/var/run/docker.sock"]

View File

@@ -1,3 +1,5 @@
# Holds the runner's working directory (/data): the .runner registration file
# and, optionally, the config file.
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
@@ -10,6 +12,21 @@ spec:
storage: 1Gi
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
data:
# The registration token can be obtained from the web UI, API or command-line.
@@ -43,7 +60,12 @@ spec:
- name: runner-data
persistentVolumeClaim:
claimName: runner-vol
- name: docker-data
persistentVolumeClaim:
claimName: docker-vol
securityContext:
# The dind-rootless image runs as the `rootless` user (UID/GID 1000);
# fsGroup makes both volumes writable for it.
fsGroup: 1000
containers:
- name: runner
@@ -68,4 +90,7 @@ spec:
volumeMounts:
- name: runner-data
mountPath: /data
# The rootless daemon keeps its images here, not under /data.
- name: docker-data
mountPath: /home/rootless/.local/share/docker

View File

@@ -44,6 +44,10 @@ spec:
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"]
@@ -68,6 +72,8 @@ spec:
- 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:
@@ -77,3 +83,14 @@ spec:
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

3
go.mod
View File

@@ -40,6 +40,7 @@ require (
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
google.golang.org/protobuf v1.36.11
gotest.tools/v3 v3.5.2
tags.cncf.io/container-device-interface v1.1.0
@@ -106,7 +107,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sync v0.22.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

18
go.sum
View File

@@ -47,10 +47,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.5.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/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
@@ -131,12 +127,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/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/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/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/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
@@ -215,8 +207,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/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
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/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M=
@@ -252,6 +242,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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -260,18 +252,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.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
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=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

View File

@@ -51,6 +51,7 @@ func Execute(ctx context.Context) {
RunE: runDaemon(ctx, &daemArgs, &configFile),
}
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)
// ./gitea-runner exec

View File

@@ -49,10 +49,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
return fmt.Errorf("failed to load registration file: %w", err)
}
lbls := reg.Labels
if len(cfg.Runner.Labels) > 0 {
lbls = cfg.Runner.Labels
}
lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
ls := labels.Labels{}
for _, l := range lbls {
@@ -204,6 +201,29 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
type daemonArgs struct {
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.

View File

@@ -12,6 +12,32 @@ import (
"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) {
got, err := getDockerSocketPath("tcp://docker.example:2376")
require.NoError(t, err)

View File

@@ -387,7 +387,10 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
ls := make([]string, len(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
}
// register new runner.

View File

@@ -15,11 +15,11 @@ import (
func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
err := registerNoInteractive(t.Context(), "", &registerArgs{
Labels: "label:invalid",
Labels: "ubuntu:host,,broken",
Token: "token",
InstanceAddr: "http://localhost:3000",
})
assert.Error(t, err, "unsupported schema: invalid")
assert.ErrorContains(t, err, "empty label")
}
func TestRegisterInputsValidate(t *testing.T) {
@@ -40,8 +40,8 @@ func TestRegisterInputsValidate(t *testing.T) {
},
{
name: "invalid label",
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:vm:bad"}},
wantErr: "unsupported schema: vm",
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{""}},
wantErr: "empty label",
},
{
name: "valid",
@@ -62,7 +62,9 @@ func TestRegisterInputsValidate(t *testing.T) {
func TestValidateLabels(t *testing.T) {
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) {
@@ -106,11 +108,10 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
t.Run("labels from config skip the labels stage", func(t *testing.T) {
cfg := &config.Config{}
cfg.Runner.Labels = []string{"ubuntu:host", "ubuntu:vm:bad"}
cfg.Runner.Labels = []string{"ubuntu:host", "", "pool:e57e18d4"}
inputs := &registerInputs{}
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
// only the valid label survives
require.Equal(t, []string{"ubuntu:host"}, inputs.Labels)
require.Equal(t, []string{"ubuntu:host", "pool:e57e18d4"}, inputs.Labels)
})
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) {
inputs := &registerInputs{}
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)
})
t.Run("labels containing a colon are accepted", func(t *testing.T) {
inputs := &registerInputs{}
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) {
inputs := &registerInputs{}
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))

View File

@@ -75,7 +75,7 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
cfg.Runner.Envs = map[string]string{"EXISTING": "value"}
reg := &config.Registration{
Name: "runner",
Labels: []string{"ubuntu:host", "bad:vm:label"},
Labels: []string{"ubuntu:host", "", "pool:e57e18d4"},
}
cli := clientmocks.NewClient(t)
cli.On("Address").Return("https://gitea.example/").Maybe()
@@ -83,7 +83,8 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
r := NewRunner(cfg, reg, cli)
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, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"])
require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])

View File

@@ -4,7 +4,7 @@
package labels
import (
"fmt"
"errors"
"strings"
)
@@ -17,13 +17,20 @@ type Label struct {
Name string
Schema 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) {
if str == "" {
return nil, errors.New("empty label")
}
splits := strings.SplitN(str, ":", 3)
label := &Label{
Name: splits[0],
Schema: "host",
Schema: SchemeHost,
Arg: "",
}
if len(splits) >= 2 {
@@ -33,7 +40,12 @@ func Parse(str string) (*Label, error) {
label.Arg = splits[2]
}
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
}
@@ -59,7 +71,7 @@ func (l Labels) PickPlatform(runsOn []string) string {
case SchemeHost:
platforms[label.Name] = "-self-hosted"
default:
// It should not happen, because Parse has checked it.
// unreachable: Parse only produces host or docker schemas
continue
}
}
@@ -94,7 +106,7 @@ func (l Labels) ToStrings() []string {
ls := make([]string, 0, len(l))
for _, label := range l {
lbl := label.Name
if label.Schema != "" {
if !label.Opaque && label.Schema != "" {
lbl += ":" + label.Schema
if label.Arg != "" {
lbl += ":" + label.Arg

View File

@@ -43,8 +43,28 @@ func TestParse(t *testing.T) {
},
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",
want: &Label{
Name: "ubuntu:vm:ubuntu-18.04",
Schema: "host",
Arg: "",
Opaque: true,
},
wantErr: false,
},
{
args: "",
want: nil,
wantErr: true,
},
@@ -116,8 +136,8 @@ func TestPickPlatform(t *testing.T) {
}
func TestNames(t *testing.T) {
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host")
require.Equal(t, []string{"ubuntu", "self-hosted"}, ls.Names())
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host", "pool:e57e18d4")
require.Equal(t, []string{"ubuntu", "self-hosted", "pool:e57e18d4"}, ls.Names())
require.Empty(t, Labels{}.Names())
}
@@ -126,10 +146,26 @@ func TestToStrings(t *testing.T) {
"ubuntu:docker://node:18",
"self-hosted:host",
"bare",
"pool:e57e18d4",
)
require.Equal(t, []string{
"ubuntu:docker://node:18",
"self-hosted:host",
"bare:host",
"pool:e57e18d4",
}, 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}))
}

View File

@@ -12,14 +12,18 @@ CONFIG_ARG=""
if [[ ! -z "${CONFIG_FILE}" ]]; then
CONFIG_ARG="--config ${CONFIG_FILE}"
fi
EXTRA_ARGS=""
LABEL_ARGS=""
if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then
EXTRA_ARGS="${EXTRA_ARGS} --labels ${GITEA_RUNNER_LABELS}"
LABEL_ARGS="--labels ${GITEA_RUNNER_LABELS}"
fi
EXTRA_ARGS="${LABEL_ARGS}"
if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then
EXTRA_ARGS="${EXTRA_ARGS} --ephemeral"
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
RUN_ARGS="${RUN_ARGS} --once"
fi