Compare commits

..

5 Commits

Author SHA1 Message Date
silverwind
b4a64b97dd feat: support --platform and --pull in container.options (#1104)
Fixes https://gitea.com/gitea/runner/issues/667
Fixes https://gitea.com/gitea/runner/pulls/1103

`container.options` is parsed with docker/cli's `addFlags`, which omits the flags docker/cli registers on the `create` and `run` commands themselves. Those were rejected as unknown — most visibly `--platform`.

| Flag | Behavior | Why |
| --- | --- | --- |
| `--platform` | overrides the runner-wide container architecture | the only way to pick an image architecture per job, e.g. across a matrix |
| `--pull always\|missing\|never` | `always` forces a pull, `never` skips it | `force_pull` is runner-wide config today, with no per-job control |

Both are resolved in `NewContainer` because the image pull runs before the container is created and the two have to agree.

`--name`, `--quiet` and `--disable-content-trust` are now accepted and ignored, and `--use-api-socket` is rejected pointing at `container.docker_host`, so a valid `docker create` line no longer fails outright.

<sub>Written by Claude (Opus 4.8).</sub>

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1104
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-24 16:42:30 +00:00
bircni
26f9fb12af fix: clean service containers after failed job setup (#1066)
Fixes https://gitea.com/gitea/runner/issues/659

Docker teardown was chained with `Then`, which stops on the previous step's error and on a cancelled context, so the first failure orphaned everything downstream. A volume that is still in use, or a flaky daemon, left the service containers and the job network behind — matching the reports in the issue, where the leaks show up after failed or overloaded jobs.

Cleanup is now straight-line and best-effort: every step runs regardless of what failed before it, and the errors are joined. Two things follow from that:

- Service containers are removed before the job volumes, since a service can hold one via `--volumes-from` in its options. The network stays last, once every container has detached.
- Only job container and volume errors are returned. Service and network errors are logged, as before, because `stopJobContainer()` also runs as the pre-flight step of job start, where a network cleanup error must not abort the job.

The nil check on `rc.JobContainer` is kept, but it is not the leak reporters are hitting: `container.NewContainer` never returns nil in the docker build, so cleanup with no job container is only reachable in the `WITHOUT_DOCKER` stub.

Addresses https://gitea.com/gitea/runner/pulls/1066#issuecomment-1239073.

Not covered here: the `buildx_buildkit_*` volumes reported in the issue are created by buildx inside the job, not by the runner, so no runner-side teardown removes them.

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1066
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-07-24 16:36:09 +00:00
Zettat123
c9c4957e38 feat: support reading cache.external_secret from a file (#1100)
This PR adds a new `cache.external_secret_file` config, which points at a file holding the secret. So the secret can come from a mounted Kubernetes/Docker secret while the rest of the config stays plain text.

```yaml
cache:
  external_server: "http://cache-host:8088/"
  external_secret_file: /path/to/cache_external_secret
```

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1100
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-23 06:26:10 +00:00
Renovate Bot
b1a02cdd5d chore(deps): update docker docker tag to v29.6.2 (#1101)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-23 06:19:27 +00:00
Renovate Bot
0fd8602ac3 chore(deps): update actions/setup-go action to v7 (#1102)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-go](https://github.com/actions/setup-go) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>actions/setup-go (actions/setup-go)</summary>

### [`v7.0.0`](https://github.com/actions/setup-go/releases/tag/v7.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v7.0.0...v7.0.0)

##### What's Changed

- Migrate to ESM and upgrade dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;763](https://github.com/actions/setup-go/pull/763)
- chore(deps): bump [@&#8203;actions/cache](https://github.com/actions/cache) to 6.2.0 by [@&#8203;philip-gai](https://github.com/philip-gai) in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

##### New Contributors

- [@&#8203;philip-gai](https://github.com/philip-gai) made their first contribution in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v7.0.0>

### [`v7`](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0)

### [`v6.5.0`](https://github.com/actions/setup-go/releases/tag/v6.5.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.4.0...v6.5.0)

##### What's Changed

##### Dependency update

- Upgrade actions dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- Upgrade [@&#8203;types/node](https://github.com/types/node) and typescript-eslint dependencies to resolve npm audit findings by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;755](https://github.com/actions/setup-go/pull/755)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to 5.1.0, log cache write denied by [@&#8203;jasongin](https://github.com/jasongin) in [#&#8203;758](https://github.com/actions/setup-go/pull/758)
- Upgrade version to 6.5.0 in package.json and package-lock.json by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;762](https://github.com/actions/setup-go/pull/762)

##### New Contributors

- [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- [@&#8203;jasongin](https://github.com/jasongin) made their first contribution in [#&#8203;758](https://github.com/actions/setup-go/pull/758)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.5.0>

### [`v6.4.0`](https://github.com/actions/setup-go/releases/tag/v6.4.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.3.0...v6.4.0)

##### What's Changed

##### Enhancement

- Add go-download-base-url input for custom Go distributions by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

##### Dependency update

- Upgrade minimatch from 3.1.2 to 3.1.5 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;727](https://github.com/actions/setup-go/pull/727)

##### Documentation update

- Rearrange README.md, add advanced-usage.md by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;724](https://github.com/actions/setup-go/pull/724)
- Fix Microsoft build of Go link by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;734](https://github.com/actions/setup-go/pull/734)

##### New Contributors

- [@&#8203;gdams](https://github.com/gdams) made their first contribution in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.4.0>

### [`v6.3.0`](https://github.com/actions/setup-go/releases/tag/v6.3.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.2.0...v6.3.0)

##### What's Changed

- Update default Go module caching to use go.mod by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;705](https://github.com/actions/setup-go/pull/705)
- Fix golang download url to go.dev by [@&#8203;178inaba](https://github.com/178inaba) in [#&#8203;469](https://github.com/actions/setup-go/pull/469)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.3.0>

### [`v6.2.0`](https://github.com/actions/setup-go/releases/tag/v6.2.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.1.0...v6.2.0)

##### What's Changed

##### Enhancements

- Example for restore-only cache in documentation  by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;696](https://github.com/actions/setup-go/pull/696)
- Update Node.js version in action.yml by [@&#8203;ccoVeille](https://github.com/ccoVeille) in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- Documentation update of actions/checkout by [@&#8203;deining](https://github.com/deining) in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

##### Dependency updates

- Upgrade js-yaml from 3.14.1 to 3.14.2 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;682](https://github.com/actions/setup-go/pull/682)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to v5 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;695](https://github.com/actions/setup-go/pull/695)
- Upgrade actions/checkout from 5 to 6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;686](https://github.com/actions/setup-go/pull/686)
- Upgrade qs from 6.14.0 to 6.14.1 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;703](https://github.com/actions/setup-go/pull/703)

##### New Contributors

- [@&#8203;ccoVeille](https://github.com/ccoVeille) made their first contribution in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- [@&#8203;deining](https://github.com/deining) made their first contribution in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.2.0>

### [`v6.1.0`](https://github.com/actions/setup-go/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6...v6.1.0)

##### What's Changed

##### Enhancements

- Fall back to downloading from go.dev/dl instead of storage.googleapis.com/golang by [@&#8203;nicholasngai](https://github.com/nicholasngai) in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- Add support for .tool-versions file and update workflow by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- Add comprehensive breaking changes documentation for v6 by [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

##### Dependency updates

- Upgrade eslint-config-prettier from 10.0.1 to 10.1.8 and document breaking changes in v6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;617](https://github.com/actions/setup-go/pull/617)
- Upgrade actions/publish-action from 0.3.0 to 0.4.0 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;641](https://github.com/actions/setup-go/pull/641)
- Upgrade semver and [@&#8203;types/semver](https://github.com/types/semver) by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;652](https://github.com/actions/setup-go/pull/652)

##### New Contributors

- [@&#8203;nicholasngai](https://github.com/nicholasngai) made their first contribution in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- [@&#8203;priya-kinthali](https://github.com/priya-kinthali) made their first contribution in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.1.0>

</details>

---

### 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/1102
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-23 05:41:55 +00:00
15 changed files with 407 additions and 67 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: goreleaser - name: goreleaser

View File

@@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Import GPG key - name: Import GPG key

View File

@@ -18,7 +18,7 @@ jobs:
DOCKER_CONFIG: /tmp/docker-noauth DOCKER_CONFIG: /tmp/docker-noauth
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: prepare anonymous docker config - name: prepare anonymous docker config

View File

@@ -17,7 +17,7 @@ RUN make clean && make build
### DIND VARIANT ### DIND VARIANT
# #
# #
FROM docker:29.6.1-dind AS dind FROM docker:29.6.2-dind AS dind
ARG VERSION=dev ARG VERSION=dev
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT ### DIND-ROOTLESS VARIANT
# #
# #
FROM docker:29.6.1-dind-rootless AS dind-rootless FROM docker:29.6.2-dind-rootless AS dind-rootless
ARG VERSION=dev ARG VERSION=dev

View File

@@ -224,6 +224,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
dir: /data/actcache dir: /data/actcache
port: 8088 port: 8088
external_secret: "replace-with-a-strong-random-secret" external_secret: "replace-with-a-strong-random-secret"
# external_secret_file: /path/to/secret # secret can also be passed via a file
``` ```
2. Start the server: 2. Start the server:
@@ -238,6 +239,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
cache: cache:
external_server: "http://<cache-server-host>:8088/" external_server: "http://<cache-server-host>:8088/"
external_secret: "replace-with-a-strong-random-secret" # must match the server external_secret: "replace-with-a-strong-random-secret" # must match the server
# external_secret_file: /path/to/secret # secret can also be passed via a file
``` ```
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories. Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.

View File

@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"errors"
"fmt"
"io"
"slices"
"github.com/kballard/go-shellquote"
"github.com/spf13/pflag"
)
const (
pullPolicyAlways = "always"
pullPolicyMissing = "missing"
pullPolicyNever = "never"
)
var pullPolicies = []string{pullPolicyAlways, pullPolicyMissing, pullPolicyNever}
// createFlags are the flags docker/cli registers on the `create` and `run` commands
// instead of in addFlags, so they are not part of containerOptions.
type createFlags struct {
platform string
pull string
name string
useAPISocket bool
}
func registerCreateFlags(flags *pflag.FlagSet) *createFlags {
cf := new(createFlags)
flags.StringVar(&cf.platform, "platform", "", "Set platform if server is multi-platform capable")
flags.StringVar(&cf.pull, "pull", pullPolicyMissing, `Pull image before creating ("always", "missing", "never")`)
flags.StringVar(&cf.name, "name", "", "Assign a name to the container")
flags.BoolVar(&cf.useAPISocket, "use-api-socket", false, "Bind mount Docker API socket and required auth")
// Accepted without effect: pull progress is only logged at debug level, and docker
// no longer implements content trust.
flags.BoolP("quiet", "q", false, "Suppress the pull output")
flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)")
return cf
}
// parseContainerOptions parses a container options string. The flags are returned even
// on error, holding whatever was read before the failure.
func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *createFlags, error) {
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
flags.SetOutput(io.Discard)
copts := addFlags(flags)
cf := registerCreateFlags(flags)
args, err := shellquote.Split(options)
if err != nil {
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
}
if err := flags.Parse(args); err != nil {
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
}
return flags, copts, cf, nil
}
// createFlagsFromOptions reads the create-level flags that have to be known before the
// container is created. Malformed options keep the defaults here and are reported by
// mergeContainerConfigs at create time.
func createFlagsFromOptions(options string) *createFlags {
_, _, cf, _ := parseContainerOptions(options)
return cf
}
func (cf *createFlags) validate() error {
if !slices.Contains(pullPolicies, cf.pull) {
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
}
if cf.useAPISocket {
return errors.New("--use-api-socket is not supported, use the runner's container.docker_host setting to expose a docker socket")
}
return nil
}

View File

@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateFlagsFromOptions(t *testing.T) {
for _, tc := range []struct {
options string
platform string
pull string
}{
{"", "", pullPolicyMissing},
{"-v /a:/b --platform=linux/arm64 --pull always", "linux/arm64", pullPolicyAlways},
{"--platform linux/arm/v7 --pull never", "linux/arm/v7", pullPolicyNever},
{`--platform "linux/amd64`, "", pullPolicyMissing}, // malformed, defaults kept
} {
t.Run(tc.options, func(t *testing.T) {
cf := createFlagsFromOptions(tc.options)
assert.Equal(t, tc.platform, cf.platform)
assert.Equal(t, tc.pull, cf.pull)
})
}
}
func TestCreateFlagsValidate(t *testing.T) {
for _, tc := range []struct {
options string
wantErr string
}{
{"--quiet --disable-content-trust --name mine", ""},
{"--pull sometimes", `invalid --pull option "sometimes"`},
{"--use-api-socket", "--use-api-socket is not supported"},
} {
t.Run(tc.options, func(t *testing.T) {
err := createFlagsFromOptions(tc.options).validate()
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
func TestNewContainerAppliesCreateFlags(t *testing.T) {
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
cr := NewContainer(input).(*containerReference)
assert.Equal(t, "linux/arm64", input.Platform)
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
NewContainer(kept)
assert.Equal(t, "linux/amd64", kept.Platform)
}

View File

@@ -35,7 +35,6 @@ import (
"github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/gobwas/glob" "github.com/gobwas/glob"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kballard/go-shellquote"
"github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
@@ -43,7 +42,6 @@ import (
"github.com/moby/moby/api/types/system" "github.com/moby/moby/api/types/system"
"github.com/moby/moby/client" "github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1" specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/pflag"
) )
// drainGracePeriod bounds how long we wait for an output-copy goroutine to // drainGracePeriod bounds how long we wait for an output-copy goroutine to
@@ -57,6 +55,12 @@ const drainGracePeriod = 2 * time.Second
func NewContainer(input *NewContainerInput) ExecutionsEnvironment { func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
cr := new(containerReference) cr := new(containerReference)
cr.input = input cr.input = input
// Resolved up front because the image pull runs before the container is created.
cf := createFlagsFromOptions(input.Options)
if cf.platform != "" {
cr.input.Platform = cf.platform
}
cr.pullPolicy = cf.pull
return cr return cr
} }
@@ -137,6 +141,11 @@ func (cr *containerReference) Start(attach bool) common.Executor {
} }
func (cr *containerReference) Pull(forcePull bool) common.Executor { func (cr *containerReference) Pull(forcePull bool) common.Executor {
if cr.pullPolicy == pullPolicyNever {
return common.NewInfoExecutor("docker pull skipped image=%s, --pull=never in the options", cr.input.Image)
}
forcePull = forcePull || cr.pullPolicy == pullPolicyAlways
return common. return common.
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull). NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
Then( Then(
@@ -232,11 +241,12 @@ func (cr *containerReference) ReplaceLogWriter(stdout, stderr io.Writer) (io.Wri
} }
type containerReference struct { type containerReference struct {
cli client.APIClient cli client.APIClient
id string id string
input *NewContainerInput input *NewContainerInput
UID int pullPolicy string
GID int UID int
GID int
// attachDone is closed by the attach() streaming goroutine once it has // attachDone is closed by the attach() streaming goroutine once it has
// drained and flushed the container's output. wait() blocks on it so the // drained and flushed the container's output. wait() blocks on it so the
// tail of the log lands before the step proceeds. // tail of the log lands before the step proceeds.
@@ -411,17 +421,13 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
} }
// parse configuration from CLI container.options // parse configuration from CLI container.options
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError) flags, copts, cf, err := parseContainerOptions(input.Options)
copts := addFlags(flags)
optionsArgs, err := shellquote.Split(input.Options)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("Cannot split container options: '%s': '%w'", input.Options, err) return nil, nil, err
} }
err = flags.Parse(optionsArgs) if err := cf.validate(); err != nil {
if err != nil { return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
return nil, nil, fmt.Errorf("Cannot parse container options: '%s': '%w'", input.Options, err)
} }
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment. // FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
@@ -476,6 +482,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
} }
hostConfig.Binds = binds hostConfig.Binds = binds
hostConfig.Mounts = mounts hostConfig.Mounts = mounts
if cf.name != "" {
logger.Warn("--name in the options will be ignored.")
}
if len(copts.netMode.Value()) > 0 { if len(copts.netMode.Value()) > 0 {
logger.Warn("--network and --net in the options will be ignored.") logger.Warn("--network and --net in the options will be ignored.")
} }

View File

@@ -454,37 +454,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.ServiceContainers = append(rc.ServiceContainers, c) rc.ServiceContainers = append(rc.ServiceContainers, c)
} }
rc.cleanUpJobContainer = func(ctx context.Context) error { rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
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
@@ -539,6 +509,41 @@ func (rc *RunContext) startJobContainer() common.Executor {
} }
} }
// cleanupJobResources removes everything the job created, continuing past failures.
// Only job container and volume errors are returned, the rest are logged.
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
removeJobContainer := rc.JobContainer != nil && !rc.Config.ReuseContainers
var errs []error
if removeJobContainer {
errs = append(errs, rc.JobContainer.Remove()(ctx))
}
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 removeJobContainer {
// after the containers using them, services can hold these via `--volumes-from`
name := rc.jobContainerName()
errs = append(errs,
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
}
if createAndDeleteNetwork {
// last, once every container has detached
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 errors.Join(errs...)
}
}
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)

View File

@@ -7,6 +7,7 @@ package runner
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"runtime" "runtime"
@@ -451,6 +452,46 @@ 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 TestCleanupJobResourcesCleansServicesWithoutJobContainer(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.cleanupJobResources("external-network", false)(context.Background())
require.NoError(t, err)
service.AssertExpectations(t)
}
// cleanup used to bail out on a previous step's error and on a cancelled context
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
jobContainer := &containerMock{}
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
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{
Name: "job",
Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
JobContainer: jobContainer,
ServiceContainers: []container.ExecutionsEnvironment{service},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx))
jobContainer.AssertExpectations(t)
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.

View File

@@ -50,7 +50,7 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
secret := cfg.Cache.ExternalSecret secret := cfg.Cache.ExternalSecret
if secret == "" { if secret == "" {
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server") return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
} }
cacheHandler, err := artifactcache.StartHandler( cacheHandler, err := artifactcache.StartHandler(
dir, dir,

View File

@@ -89,6 +89,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
if cfg.Cache.ExternalServer != "" { if cfg.Cache.ExternalServer != "" {
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
} else { } else {
warnIgnoredCacheSecret(cfg)
handler, err := artifactcache.StartHandler( handler, err := artifactcache.StartHandler(
cfg.Cache.Dir, cfg.Cache.Dir,
cfg.Cache.Host, cfg.Cache.Host,
@@ -512,14 +513,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// function the caller must invoke (typically via defer) to revoke the // function the caller must invoke (typically via defer) to revoke the
// credential when the task finishes. // credential when the task finishes.
// //
// Three modes: // Two modes:
// - Embedded handler: register in-process via RegisterJob. // - Embedded handler: register in-process via RegisterJob.
// - external_server + external_secret: POST to the remote server's // - external_server: POST to the remote server's /_internal/register, defer a
// /_internal/register, defer a POST to /_internal/revoke. This is what // POST to /_internal/revoke. This is what enables full per-job auth and
// enables full per-job auth and repo scoping over the network. // repo scoping over the network.
// - external_server alone (no secret): no-op revoker. The remote server is
// in legacy openMode and ignores the runtime token; trust is at the
// network layer.
// //
// Safe with an empty token (older Gitea did not issue one). // Safe with an empty token (older Gitea did not issue one).
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() { func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
@@ -532,6 +530,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" { if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
return r.registerExternalCacheJob(token, repo, reporter) return r.registerExternalCacheJob(token, repo, reporter)
} }
// No cache server to register against: caching is disabled, or the built-in server failed to start.
return func() {} return func() {}
} }
@@ -655,3 +654,20 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons
Capabilities: RunnerCapabilities(), Capabilities: RunnerCapabilities(),
})) }))
} }
// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server.
func warnIgnoredCacheSecret(cfg *config.Config) {
if cfg.Cache.ExternalServer != "" {
return
}
// Not using an external cache server, so any configured secret is ignored.
if cfg.Cache.ExternalSecret == "" {
return
}
// LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote.
key := "cache.external_secret"
if cfg.Cache.ExternalSecretFile != "" {
key = "cache.external_secret_file"
}
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
}

View File

@@ -132,6 +132,11 @@ cache:
# Required when external_server is set. Must be identical on every runner and the cache-server. # Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
external_secret: "" external_secret: ""
# Path to a file containing the shared secret, as an alternative to external_secret.
# Use this to keep the secret out of this file.
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
# Setting both external_secret and external_secret_file is an error.
external_secret_file: ""
# When true, reuse a cached action instead of fetching from the remote on every job. # When true, reuse a cached action instead of fetching from the remote on every job.
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit # A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
# until its cache entry expires or is manually removed. # until its cache entry expires or is manually removed.

View File

@@ -9,6 +9,7 @@ import (
"maps" "maps"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -57,13 +58,14 @@ type Runner struct {
// Cache represents the configuration for caching. // Cache represents the configuration for caching.
type Cache struct { type Cache struct {
Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true. Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true.
Dir string `yaml:"dir"` // Dir specifies the directory path for caching. Dir string `yaml:"dir"` // Dir specifies the directory path for caching.
Host string `yaml:"host"` // Host specifies the caching host. Host string `yaml:"host"` // Host specifies the caching host.
Port uint16 `yaml:"port"` // Port specifies the caching port. Port uint16 `yaml:"port"` // Port specifies the caching port.
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior. ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Required whenever ExternalServer is set; ExternalSecretFile is the alternative way to provide it.
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed. ExternalSecretFile string `yaml:"external_secret_file"` // ExternalSecretFile is the path to a file holding the ExternalSecret value, so the secret can be mounted instead of stored in the config file. LoadDefault reads it into ExternalSecret; setting both is an error.
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
} }
// Container represents the configuration for the container. // Container represents the configuration for the container.
@@ -177,6 +179,10 @@ func LoadDefault(file string) (*Config, error) {
b := true b := true
cfg.Cache.Enabled = &b cfg.Cache.Enabled = &b
} }
// Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled.
if err := resolveCacheExternalSecret(cfg); err != nil {
return nil, err
}
if *cfg.Cache.Enabled { if *cfg.Cache.Enabled {
if cfg.Cache.Dir == "" { if cfg.Cache.Dir == "" {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
@@ -186,7 +192,7 @@ func LoadDefault(file string) (*Config, error) {
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 == "" {
return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the gitea-runner cache-server") return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server")
} }
} }
if cfg.Container.WorkdirParent == "" { if cfg.Container.WorkdirParent == "" {
@@ -301,3 +307,24 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
return defined, nil return defined, nil
} }
// resolveCacheExternalSecret loads cache.external_secret from the file named by cache.external_secret_file,
// so deployments can mount the secret instead of committing it to the config file.
func resolveCacheExternalSecret(cfg *Config) error {
if cfg.Cache.ExternalSecretFile == "" {
return nil
}
if cfg.Cache.ExternalSecret != "" {
return errors.New("cache.external_secret and cache.external_secret_file are both set; configure only one of them")
}
content, err := os.ReadFile(cfg.Cache.ExternalSecretFile)
if err != nil {
return fmt.Errorf("read cache.external_secret_file %q: %w", cfg.Cache.ExternalSecretFile, err)
}
secret := strings.TrimSpace(string(content))
if secret == "" {
return fmt.Errorf("cache.external_secret_file %q contains no secret", cfg.Cache.ExternalSecretFile)
}
cfg.Cache.ExternalSecret = secret
return nil
}

View File

@@ -227,3 +227,91 @@ func TestContainerNetworkCreateOptions(t *testing.T) {
assert.Nil(t, opts.EnableIPv6) assert.Nil(t, opts.EnableIPv6)
}) })
} }
func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+secretPath+`"
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
}
func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
// the file has to be resolved even when cache is disabled
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: false
external_secret_file: "`+secretPath+`"
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
}
func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret: "inline"
external_secret_file: "`+secretPath+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "both set")
}
func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+filepath.Join(dir, "absent.secret")+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "read cache.external_secret_file")
}
func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+secretPath+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "contains no secret")
}