Compare commits

...
7 Commits
Author SHA1 Message Date
silverwind 699a97fc8c enhance: bind-mount job paths through the docker proxy (#1226)
Containers a job starts through its Docker socket, for example `docker run -v "$PWD:/src"`, `./data:/data` in docker compose, or actions like dockerfile-roast, can now bind-mount the workspace and other paths the job sees, as on a host, without `bind_workdir`.

The per-job Docker proxy rewrites container and volume create requests. A bind source, or the device of a `local` volume with `o: bind`, that lies under one of the job container's mounts is pointed at that mount's path on the daemon, read from inspecting the job container. Paths that already name a daemon path, like `GITEA_DOCKER_WORKSPACE`, and paths outside the job's mounts pass through unchanged.

The proxy now also starts when the runner runs in a container given the host's Docker socket, by placing its socket in the runner's working directory, and in rootless dind, by granting the daemon socket's group through an ACL.

Fixes https://gitea.com/gitea/runner/issues/1219
Fixes https://gitea.com/gitea/runner/issues/1193

Reviewed-on: https://gitea.com/gitea/runner/pulls/1226
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-14 20:21:58 +00:00
silverwind 19afebc53f fix: send artifacts to Gitea when jobs cannot reach the cache server (#1225)
Container jobs on a Docker bridge network isolated from the runner's cache server now upload artifacts to Gitea directly instead of timing out, with a job log warning on how to make the cache reachable.

Fixes https://gitea.com/gitea/runner/issues/1211

Reviewed-on: https://gitea.com/gitea/runner/pulls/1225
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-14 19:52:15 +00:00
silverwindandbircni 2cc3000369 fix: mount the job workspace above the repository (#1224)
Mount the job workspace volume, or the `bind_workdir` directory, above `<owner>/<repo>` instead of at the repository, like GitHub's work directory.

With the repository as a mount point, pnpm 12 puts its store in `node_modules/.pnpm-store` (https://github.com/pnpm/pnpm/pull/13536), so a restored `setup-node` pnpm cache creates `node_modules` before install. Renames into `$RUNNER_WORKSPACE` fail with `EXDEV` and removing the workspace fails with `EBUSY`.

Verified against GitHub hosted and container jobs with cold and warm caches, on volume and `bind_workdir` runners, covering checkout, cache, artifacts, github-script, the node, pnpm, bun, python, uv, go, rust, java, gradle, dotnet, ruby and terraform setup actions, docker and Dockerfile actions, buildx, compose via `GITEA_DOCKER_WORKSPACE`, services, host mode, `container.volumes` on the workspace and `exec`.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1224
Co-authored-by: silverwind <[email protected]>
2026-09-14 18:58:19 +00:00
silverwindandbircni 3d116eb0c2 fix(deps): update to actionslib v1.0.0 (#1221)
Updates `gitea.dev/actionslib` to v1.0.0, see https://gitea.com/gitea/actionslib/pulls/17:

1. First-party expression parser passing GitHub's full conformance suite, replacing actionlint
1. Expression results match GitHub, including truthiness, numbers, `fromJSON`, `toJSON` and `hashFiles` cache keys
1. Whole-value `${{ }}` works for `strategy`, `env`, `with`, `services` and `outputs`
1. Matrix validation matches GitHub

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1221
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-14 18:41:37 +00:00
e0ce6776c7 perf(cache): skip redundant store writes and scans on cache requests (#1222)
Every cache hit rewrote the entry's access time, costing a bolt write and fsync per restore. Completed entries accessed within the last minute are no longer rewritten, and the in-use and retention windows grow by that minute so nothing is evicted earlier. Same-second rewrites are skipped, reservations are inserted in one transaction, and prefix lookups use the repository index instead of scanning every entry.

The store is still opened per request. Keeping it open, as this commit first did, is faster under concurrency but stops a second process sharing the cache directory from starting its cache server.

The README caching section is condensed and corrected.

| Benchmark, v2 | main | store kept open | this PR |
| --- | ---: | ---: | ---: |
| Exact hit, 1 client | 16.42 | 0.15 | 0.24 |
| Exact hit, 8 clients | 13.70 | 0.08 | 1.28 |
| Exact hit, 32 clients | 16.01 | 0.07 | 4.26 |
| Prefix hit, 8 clients | 18.34 | 0.12 | 1.28 |
| Miss, 8 clients | 3.21 | 0.11 | 1.28 |
| Save and restore 1 MiB, 8 clients | 74.4 | 22.9 | 34.9 |
| Geomean, all 22 benchmarks | 14.03 | 0.31 | 2.11 |

Mean ms per request, medians of 4 rounds of 300 requests over 1,000 entries on an Apple M3 Max.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1222
Reviewed-by: silverwind <[email protected]>
2026-09-14 18:35:13 +00:00
Renovate Bot 69d5c22300 chore(deps): update dependencies (#1223)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update |
|---|---|---|---|---|---|
| [connectrpc.com/connect](https://github.com/connectrpc/connect-go) | `v1.20.0` → `v1.21.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/connectrpc.com%2fconnect/v1.21.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/connectrpc.com%2fconnect/v1.20.0/v1.21.0?slim=true) | require | minor |
| [github.com/docker/cli](https://github.com/docker/cli) | `v29.7.2+incompatible` → `v29.8.0+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.8.0+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.7.2+incompatible/v29.8.0+incompatible?slim=true) | require | minor |
| [github.com/moby/moby/api](https://github.com/moby/moby) | `v1.55.0` → `v1.56.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fmoby%2fapi/v1.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fmoby%2fapi/v1.55.0/v1.56.0?slim=true) | require | minor |
| [github.com/moby/moby/client](https://github.com/moby/moby) | `v0.5.1` → `v0.6.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fmoby%2fclient/v0.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fmoby%2fclient/v0.5.1/v0.6.0?slim=true) | require | minor |
| [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) | [`v0.58.0` → `v0.59.0`](https://cs.opensource.google/go/x/net/+/refs/tags/v0.58.0...refs/tags/v0.59.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fnet/v0.59.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fnet/v0.58.0/v0.59.0?slim=true) | require | minor |
| [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) | [`v0.22.0` → `v0.23.0`](https://cs.opensource.google/go/x/sync/+/refs/tags/v0.22.0...refs/tags/v0.23.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsync/v0.23.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsync/v0.22.0/v0.23.0?slim=true) | require | minor |
| [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) | [`v0.47.0` → `v0.48.0`](https://cs.opensource.google/go/x/sys/+/refs/tags/v0.47.0...refs/tags/v0.48.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fsys/v0.48.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fsys/v0.47.0/v0.48.0?slim=true) | require | minor |
| [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) | [`v0.45.0` → `v0.46.0`](https://cs.opensource.google/go/x/term/+/refs/tags/v0.45.0...refs/tags/v0.46.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fterm/v0.46.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fterm/v0.45.0/v0.46.0?slim=true) | require | minor |
| [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) | [`v0.41.0` → `v0.42.0`](https://cs.opensource.google/go/x/text/+/refs/tags/v0.41.0...refs/tags/v0.42.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2ftext/v0.42.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2ftext/v0.41.0/v0.42.0?slim=true) | require | minor |
| golang.org/x/vuln | `v1.7.0` → `v1.8.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fvuln/v1.8.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fvuln/v1.7.0/v1.8.0?slim=true) |  | minor |
| [node](https://github.com/nodejs/node) | `be23f54` → `6dac556` |  |  |  | digest |
| [node](https://github.com/nodejs/node) | `ba849c6` → `2fe369e` |  |  |  | digest |
| [tags.cncf.io/container-device-interface](https://github.com/cncf-tags/container-device-interface) | `v1.1.0` → `v1.1.1` | ![age](https://developer.mend.io/api/mc/badges/age/go/tags.cncf.io%2fcontainer-device-interface/v1.1.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/tags.cncf.io%2fcontainer-device-interface/v1.1.0/v1.1.1?slim=true) | require | patch |

---

### Release Notes

<details>
<summary>connectrpc/connect-go (connectrpc.com/connect)</summary>

### [`v1.21.0`](https://github.com/connectrpc/connect-go/releases/tag/v1.21.0)

[Compare Source](https://github.com/connectrpc/connect-go/compare/v1.20.0...v1.21.0)

#### What's Changed

> \[!IMPORTANT]
>
> This release adds a security-related feature for servers. The new handler option [`WithRequestGate`](https://pkg.go.dev/connectrpc.com/connect#WithRequestGate) runs after the request headers are available and before any message is received. Use this new option to register checks (e.g. authentication) that should happen before the request is decompressed or unmarshaled, and before any interceptors are run. See the [documentation for details](https://connectrpc.com/docs/go/interceptors/#authentication).

##### Governance

- Add pkwarren as a maintainer by [@&#8203;bufdev](https://github.com/bufdev) in [#&#8203;929](https://github.com/connectrpc/connect-go/pull/929)  🎉

##### Enhancements

- Add WithRequestGate handler option by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;962](https://github.com/connectrpc/connect-go/pull/962)
- Create release binary for protoc-gen-connect-go by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;966](https://github.com/connectrpc/connect-go/pull/966)

##### Bugfixes

- Fix issue with user-agent getting inappropriately injected into request headers by [@&#8203;jhump](https://github.com/jhump) in [#&#8203;934](https://github.com/connectrpc/connect-go/pull/934)
- Fix client streaming operations blocking indefinitely after context cancellation by [@&#8203;haru0017](https://github.com/haru0017) in [#&#8203;937](https://github.com/connectrpc/connect-go/pull/937)
- Fix flaky HTTP2 testcase assertion by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;940](https://github.com/connectrpc/connect-go/pull/940)
- Fix data race and connection leak in duplexHTTPCall by [@&#8203;emcfarlane](https://github.com/emcfarlane) in [#&#8203;938](https://github.com/connectrpc/connect-go/pull/938)
- Fix off-by-one in WithHTTPGetMaxURLSize URL length check by [@&#8203;fchimpan](https://github.com/fchimpan) in [#&#8203;955](https://github.com/connectrpc/connect-go/pull/955)
- Fix connect.WithGRPC typo in the missing client option error by [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) in [#&#8203;956](https://github.com/connectrpc/connect-go/pull/956)
- Only send a 304 when the request was a GET by [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) in [#&#8203;957](https://github.com/connectrpc/connect-go/pull/957)
- Fix panic in stream Spec and Peer when client construction failed by [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) in [#&#8203;959](https://github.com/connectrpc/connect-go/pull/959)
- Fix dropped headers on errStreamingClientConn by [@&#8203;mohit-bhandari45](https://github.com/mohit-bhandari45) in [#&#8203;964](https://github.com/connectrpc/connect-go/pull/964)

#### New Contributors

- [@&#8203;haru0017](https://github.com/haru0017) made their first contribution in [#&#8203;937](https://github.com/connectrpc/connect-go/pull/937)
- [@&#8203;dongjiang1989](https://github.com/dongjiang1989) made their first contribution in [#&#8203;945](https://github.com/connectrpc/connect-go/pull/945)
- [@&#8203;fchimpan](https://github.com/fchimpan) made their first contribution in [#&#8203;955](https://github.com/connectrpc/connect-go/pull/955)
- [@&#8203;NotAFlightRisk](https://github.com/NotAFlightRisk) made their first contribution in [#&#8203;956](https://github.com/connectrpc/connect-go/pull/956)
- [@&#8203;mohit-bhandari45](https://github.com/mohit-bhandari45) made their first contribution in [#&#8203;964](https://github.com/connectrpc/connect-go/pull/964)

**Full Changelog**: <https://github.com/connectrpc/connect-go/compare/v1.20.0...v1.21.0>

</details>

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.8.0+incompatible`](https://github.com/docker/cli/compare/v29.7.2...v29.8.0)

[Compare Source](https://github.com/docker/cli/compare/v29.7.2...v29.8.0)

</details>

<details>
<summary>moby/moby (github.com/moby/moby/client)</summary>

### [`v0.6.0`](https://github.com/moby/moby/compare/v0.5.1...v0.6.0)

[Compare Source](https://github.com/moby/moby/compare/v0.5.1...v0.6.0)

</details>

<details>
<summary>cncf-tags/container-device-interface (tags.cncf.io/container-device-interface)</summary>

### [`v1.1.1`](https://github.com/cncf-tags/container-device-interface/releases/tag/v1.1.1)

[Compare Source](https://github.com/cncf-tags/container-device-interface/compare/v1.1.0...v1.1.1)

#### What's Changed

- spec: Fix comment to reference containerEdits field by [@&#8203;zrezke](https://github.com/zrezke) in [#&#8203;303](https://github.com/cncf-tags/container-device-interface/pull/303)
- cdi: inject mount UID/GID mappings if user NS is in use. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;288](https://github.com/cncf-tags/container-device-interface/pull/288)
- cdi,SPECS.md: allow empty cgroup permissions. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;301](https://github.com/cncf-tags/container-device-interface/pull/301)
- .github: bump golangci-lint to v2.9.0. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;307](https://github.com/cncf-tags/container-device-interface/pull/307)
- add local build and install procedure in README.md. by [@&#8203;fujitatomoya](https://github.com/fujitatomoya) in [#&#8203;305](https://github.com/cncf-tags/container-device-interface/pull/305)
- Revert to default spec dirs if none are specified by [@&#8203;elezar](https://github.com/elezar) in [#&#8203;310](https://github.com/cncf-tags/container-device-interface/pull/310)
- SPEC.md: fix a few typos/clarify a sentence. by [@&#8203;klihub](https://github.com/klihub) in [#&#8203;312](https://github.com/cncf-tags/container-device-interface/pull/312)
- add beginner's tutorial to see how CDI works. by [@&#8203;fujitatomoya](https://github.com/fujitatomoya) in [#&#8203;311](https://github.com/cncf-tags/container-device-interface/pull/311)
- Makefile: add GO\_EXTRAFLAGS to go build call by [@&#8203;koenkooi](https://github.com/koenkooi) in [#&#8203;313](https://github.com/cncf-tags/container-device-interface/pull/313)
- Remove runtime-tools dependency by [@&#8203;elezar](https://github.com/elezar) in [#&#8203;317](https://github.com/cncf-tags/container-device-interface/pull/317)
- schema: add validation for top-level container edits by [@&#8203;marquiz](https://github.com/marquiz) in [#&#8203;323](https://github.com/cncf-tags/container-device-interface/pull/323)
- parser: guard single-character vendor and class names against a slice-bounds panic by [@&#8203;thc1006](https://github.com/thc1006) in [#&#8203;321](https://github.com/cncf-tags/container-device-interface/pull/321)
- chore(deps): replace deprecated gopkg.in/yaml.v3 for go.yaml.in/yaml/v3 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;328](https://github.com/cncf-tags/container-device-interface/pull/328)
- schema: make validation error private by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;329](https://github.com/cncf-tags/container-device-interface/pull/329)
- pkg/cdi: stop embedding Cache mutex by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;335](https://github.com/cncf-tags/container-device-interface/pull/335)
- build(deps): bump actions/stale from 10 to 11 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;327](https://github.com/cncf-tags/container-device-interface/pull/327)
- specs-go: add test coverage by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;332](https://github.com/cncf-tags/container-device-interface/pull/332)
- replace strings.Split for strings.Cut by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;336](https://github.com/cncf-tags/container-device-interface/pull/336)
- schema, validation: minor fixes and modernization by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;330](https://github.com/cncf-tags/container-device-interface/pull/330)
- pkg/cdi: deprecate ErrStopScan and use os.ReadDir for scanning Spec directories by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;339](https://github.com/cncf-tags/container-device-interface/pull/339)
- Bump actions/checkout from 6 to 7 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;318](https://github.com/cncf-tags/container-device-interface/pull/318)
- pkg/cdi: synchronize access to specDirs by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;337](https://github.com/cncf-tags/container-device-interface/pull/337)
- schema: migrate to github.com/santhosh-tekuri/jsonschema/v6 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;331](https://github.com/cncf-tags/container-device-interface/pull/331)
- pkg/cdi: fix deprecations, add doc-links and "go fix" by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;338](https://github.com/cncf-tags/container-device-interface/pull/338)
- internal/validation/k8s: remove unused validation helpers by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;334](https://github.com/cncf-tags/container-device-interface/pull/334)
- specs-go: remove x/mod semver dependency by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;333](https://github.com/cncf-tags/container-device-interface/pull/333)
- chore(deps): github.com/stretchr/testify v1.12.1 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;342](https://github.com/cncf-tags/container-device-interface/pull/342)
- ci: update golangci-lint to v2.13, and fix linting by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;352](https://github.com/cncf-tags/container-device-interface/pull/352)
- ci: apply zizmor hardening fixes by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;350](https://github.com/cncf-tags/container-device-interface/pull/350)
- specs-go: fix benchmark compatibility with go1.19 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;353](https://github.com/cncf-tags/container-device-interface/pull/353)
- pkg/cdi: fix flaky TestRefreshCache by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;355](https://github.com/cncf-tags/container-device-interface/pull/355)
- ci: test against oldest supported, oldstable, and stable Go versions by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;351](https://github.com/cncf-tags/container-device-interface/pull/351)
- pkg/cdi: TestRefreshCache: consolidate cache state checks by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;357](https://github.com/cncf-tags/container-device-interface/pull/357)
- build(deps): bump actions/checkout from 7.0.0 to 7.0.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;349](https://github.com/cncf-tags/container-device-interface/pull/349)
- remove "sigs.k8s.io/yaml" dependency by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;340](https://github.com/cncf-tags/container-device-interface/pull/340)
- build(deps): bump actions/setup-go from 6.4.0 to 7.0.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;348](https://github.com/cncf-tags/container-device-interface/pull/348)
- build(deps): bump golangci/golangci-lint-action from 9.2.1 to 9.3.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;346](https://github.com/cncf-tags/container-device-interface/pull/346)
- build(deps): bump codespell-project/actions-codespell from 2.1 to 2.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;347](https://github.com/cncf-tags/container-device-interface/pull/347)
- pkg/cdi: fix, and cleanup some locks, and use RWMutex by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;345](https://github.com/cncf-tags/container-device-interface/pull/345)
- update minimum go version to go1.23 and modernize some code by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;354](https://github.com/cncf-tags/container-device-interface/pull/354)
- chore(deps): github.com/fsnotify/fsnotify v1.7.0 by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;359](https://github.com/cncf-tags/container-device-interface/pull/359)
- pkg/cdi: clean up watcher lifecycle handling and simplify refresh logic by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;358](https://github.com/cncf-tags/container-device-interface/pull/358)
- pkg/cdi: fix cache refresh handling for filesystem events by [@&#8203;thaJeztah](https://github.com/thaJeztah) in [#&#8203;344](https://github.com/cncf-tags/container-device-interface/pull/344)
- Bump version to v1.1.1 by [@&#8203;elezar](https://github.com/elezar) in [#&#8203;361](https://github.com/cncf-tags/container-device-interface/pull/361)

#### New Contributors

- [@&#8203;zrezke](https://github.com/zrezke) made their first contribution in [#&#8203;303](https://github.com/cncf-tags/container-device-interface/pull/303)
- [@&#8203;fujitatomoya](https://github.com/fujitatomoya) made their first contribution in [#&#8203;305](https://github.com/cncf-tags/container-device-interface/pull/305)
- [@&#8203;koenkooi](https://github.com/koenkooi) made their first contribution in [#&#8203;313](https://github.com/cncf-tags/container-device-interface/pull/313)
- [@&#8203;thc1006](https://github.com/thc1006) made their first contribution in [#&#8203;321](https://github.com/cncf-tags/container-device-interface/pull/321)

**Full Changelog**: <https://github.com/cncf-tags/container-device-interface/compare/v1.1.0...v1.1.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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/1223
Reviewed-by: bircni <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-14 10:58:44 +00:00
silverwind 54978255f5 perf: cut redundant work out of job setup and teardown (#1218)
Implement speedups to job start and shutdown.

- Create the job container while services are still becoming healthy, and poll their health at a flat one second instead of a 2s to 32s doubling backoff
- Pull each service image once instead of twice, and fetch a warm action cache once instead of twice
- Report the job result before reclaiming its volumes, and reap volumes stranded by a runner that died mid-job

| Step | Scenario | Before | After |
| --- | --- | --- | --- |
| Complete job | Large workspace volume | 4.2s | 0.4s |
| Set up job | One service, 2s health interval | 7.08s | 3.26s |
| Set up job | Two cached actions from github.com | 1.81s | 1.34s |
| Set up job | Two cached actions from gitea.com | 2.42s | 1.95s |
| Set up job | Two actions, cold action cache | 6.62s | unchanged |
| Set up job | Minimal job, no services or actions | 0.62s | unchanged |

Assisted-by: Claude Code:Opus 5
Reviewed-on: https://gitea.com/gitea/runner/pulls/1218
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-10 19:00:06 +00:00
42 changed files with 1522 additions and 496 deletions

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
# daemon retains them between runs, so this is usually a fast manifest re-check. # daemon retains them between runs, so this is usually a fast manifest re-check.
- name: pre-pull test images - name: pre-pull test images
env: env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
run: | run: |
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
+2 -2
View File
@@ -19,7 +19,7 @@ DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected] # renovate: datasource=go GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected] # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.7.0 # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8 GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
@@ -161,7 +161,7 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless) test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET) @./scripts/test-dind.sh $(TARGET)
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2 # renovate: datasource=docker E2E_JOB_IMAGE ?= node:24-bookworm@sha256:6dac556d980b7f0e5498d08f08cee0ca67798b4ad6c23964a9214920e67758d0 # renovate: datasource=docker
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_CONCURRENCY ?= 8 E2E_CONCURRENCY ?= 8
+32 -51
View File
@@ -268,7 +268,7 @@ Unlike GitHub, a job whose steps run on the host (a `host` label without `contai
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`) #### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`: Containers a job starts through the Docker socket can bind-mount the workspace by the job's own path, as on a host, for example `docker run -v "$PWD:/src"`. Without the Docker proxy, for example with a remote `DOCKER_HOST`, use `GITEA_DOCKER_WORKSPACE`, the path the daemon sees, as the prefix of workspace binds, with `.` as the fallback for local use:
```yaml ```yaml
volumes: volumes:
@@ -308,63 +308,44 @@ A password in a proxy URL is hidden in job logs. Any step can still read it, bec
#### 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, so runners do not share cached entries. When the runner itself runs in Docker, set `cache.host` to an address job containers can reach and `cache.port` to a fixed published port, or put jobs on a shared `container.network`.
**Sharing a cache between runners**
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
```yaml
# cache-server.yaml
cache:
dir: /data/actcache
port: 8088
external_secret: "<secret>" # or external_secret_file: /path/to/secret
```
```bash
gitea-runner -c cache-server.yaml cache-server
```
```yaml
# each runner's config
cache:
external_server: "http://<cache-server-host>:8088/"
external_secret: "<secret>"
```
Jobs connect to `external_server` too, so point it at the reverse proxy if one fronts the server. `--dir`, `--host` and `--port` override the matching `cache` keys. Eviction settings take effect on the cache server, not on the runners.
Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower cache requests.
**Eviction** **Eviction**
An entry nothing has read or written for `retention` is removed, and a repository past `repo_size_limit` loses its least recently accessed entries until it fits; `size_limit` caps the whole cache the same way. Age alone never retires an entry still in use, and whatever these allow, the cache keeps free space above `health_check.min_free_disk_space_mb` when health checks are enabled. Entries not read or written within `retention` (default `168h`) are removed. A repository over `repo_size_limit` (default `10GB`) loses its least recently used entries, and `size_limit` (off by default) caps the whole cache the same way. Entries in use are never removed. The cache also keeps 1024 MiB free on its volume, or `health_check.min_free_disk_space_mb` when health checks are enabled. See [config.example.yaml](internal/pkg/config/config.example.yaml) for all options.
These apply where the cache server runs, so on a shared server they belong in *its* config, not the runners'. See `retention`, `repo_size_limit`, `size_limit` and `sweep_interval` in [config.example.yaml](internal/pkg/config/config.example.yaml) for units and defaults.
**Cache service v2** **Cache service v2**
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with: `actions/cache` v3.4.0, v4.2.0 and later use the cache service v2 API, which the runner serves by default. These actions fall back to v1 on hosts they do not recognize as GitHub, so the runner removes that check from them while a job runs. This also lets the stock `actions/upload-artifact` v4.4.0 and `actions/download-artifact` v4.1.8 and later work without the `gitea-upload-artifact` fork. With `runner.patch_actions: false`, the cache stays on v1 and the artifact actions fail.
```yaml With v2, artifact calls also go through the cache server, so jobs must be able to reach it. `cache.v2: false` sends them to Gitea directly, unless the Gitea URL has a path or `runner.insecure` is set with HTTPS.
cache:
v2: false
```
Those actions refuse any host they do not take for GitHub, so the runner edits that check out of the bundle on its way into the job and puts the shared copy back afterwards. A bundle it does not recognise is left alone. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork, so it is made whatever `v2` says. Set `runner.patch_actions: false` to leave bundles as shipped; the artifact actions then refuse and the cache client keeps to v1.
With v2 the job's artifact calls go via the cache server, so jobs need to reach it to upload artifacts, not just to cache. `v2: false` sends them to Gitea directly.
**Shared cache across multiple runners**
Run one dedicated `gitea-runner cache-server` that all runners point at.
1. Create a config file for the cache server host:
```yaml
cache:
dir: /data/actcache
port: 8088
external_secret: "replace-with-a-strong-random-secret"
# external_secret_file: /path/to/secret # secret can also be passed via a file
```
2. Start the server:
```bash
gitea-runner -c cache-server-config.yaml cache-server
```
3. On every runner:
```yaml
cache:
external_server: "http://<cache-server-host>:8088/"
external_secret: "replace-with-a-strong-random-secret" # must match the server
# external_secret_file: /path/to/secret # secret can also be passed via a file
```
Jobs reach the cache server at `external_server`, so when a reverse proxy fronts the server, point `external_server` at the proxy. The cache server itself needs no extra configuration.
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
#### Official Docker image #### Official Docker image
+29 -20
View File
@@ -20,7 +20,6 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -784,14 +783,9 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
if exact != nil { if exact != nil {
return exact, nil return exact, nil
} }
prefixPattern := "^" + regexp.QuoteMeta(prefix)
re, err := regexp.Compile(prefixPattern)
if err != nil {
continue
}
if err := db.FindOne(cache, if err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo). bolthold.Where("Repo").Eq(repo).Index("Repo").
And("Key").RegExp(re). And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }).
And("Version").Eq(version). And("Version").Eq(version).
And("Complete").Eq(true). And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil { SortBy("CreatedAt").Reverse()); err != nil {
@@ -834,14 +828,16 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
} }
func insertCache(db *bolthold.Store, cache *Cache) error { func insertCache(db *bolthold.Store, cache *Cache) error {
if err := db.Insert(bolthold.NextSequence(), cache); err != nil { return db.Bolt().Update(func(tx *bbolt.Tx) error {
return fmt.Errorf("insert cache: %w", err) if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil {
} return fmt.Errorf("insert cache: %w", err)
// write back id to db }
if err := db.Update(cache.ID, cache); err != nil { // write back id to db
return fmt.Errorf("write back id to db: %w", err) if err := db.TxUpdate(tx, cache.ID, cache); err != nil {
} return fmt.Errorf("write back id to db: %w", err)
return nil }
return nil
})
} }
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete // touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
@@ -866,6 +862,9 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
if requireIncomplete && cache.Complete { if requireIncomplete && cache.Complete {
return fmt.Errorf("cache %d: already complete", id) return fmt.Errorf("cache %d: already complete", id)
} }
if !touchNeeded(cache) {
return nil
}
cache.UsedAt = time.Now().Unix() cache.UsedAt = time.Now().Unix()
return db.Update(cache.ID, cache) return db.Update(cache.ID, cache)
} }
@@ -875,9 +874,11 @@ const (
defaultSweepInterval = time.Hour defaultSweepInterval = time.Hour
// inUseGrace matches artifactURLTTL so an entry outlives every signed URL still usable touchStale = time.Minute // how stale a completed entry's UsedAt may get before an access rewrites it, sparing hits the fsync
// for it, and no sweep cuts off a download in progress.
inUseGrace = artifactURLTTL // inUseGrace covers artifactURLTTL plus the touchStale lag so an entry outlives every signed URL
// still usable for it, and no sweep cuts off a download in progress.
inUseGrace = artifactURLTTL + touchStale
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts // uploadStallTimeout is how long a reservation may sit without a chunk before it counts
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize // as abandoned. Widening it also widens the window for findExactCache to hand a finalize
@@ -971,7 +972,7 @@ func (h *Handler) evictExpired(db *bolthold.Store) {
return return
} }
// Never below inUseGrace, or a short retention would outrun a signed URL already issued. // Never below inUseGrace, or a short retention would outrun a signed URL already issued.
window := max(h.policy.Retention, inUseGrace) window := max(h.policy.Retention+touchStale, inUseGrace)
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt")) h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
} }
@@ -1072,10 +1073,18 @@ func inUse(cache *Cache) bool {
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
} }
// touchNeeded skips a write that would not change the second, and completed entries fresher than touchStale.
func touchNeeded(cache *Cache) bool {
return cache.UsedAt < time.Now().Unix() && (!cache.Complete || time.Since(time.Unix(cache.UsedAt, 0)) >= touchStale)
}
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot // touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
// go through touchCache, which opens its own store and would block on the exclusive lock // go through touchCache, which opens its own store and would block on the exclusive lock
// for as long as the caller holds one. // for as long as the caller holds one.
func (h *Handler) touch(db *bolthold.Store, cache *Cache) { func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
if !touchNeeded(cache) {
return
}
cache.UsedAt = time.Now().Unix() cache.UsedAt = time.Now().Unix()
if err := db.Update(cache.ID, cache); err != nil { if err := db.Update(cache.ID, cache); err != nil {
h.logger.Warnf("touch cache: %v", err) h.logger.Warnf("touch cache: %v", err)
+21 -1
View File
@@ -771,7 +771,7 @@ func TestHandler_gcCache(t *testing.T) {
Key: "test_key_3", Key: "test_key_3",
Version: "test_version", Version: "test_version",
Complete: true, Complete: true,
UsedAt: now.Add(-(testRetention + time.Second)).Unix(), UsedAt: now.Add(-(testRetention + touchStale + time.Second)).Unix(),
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(), CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
}, },
Kept: false, Kept: false,
@@ -1046,6 +1046,26 @@ func TestHandler_gcCacheInterval(t *testing.T) {
assert.Empty(t, keptKeys(t, handler, []*Cache{cache})) assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
} }
func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
fresh := &Cache{Repo: testRepo, Key: "fresh", Version: "v", Complete: true, UsedAt: time.Now().Unix()}
pending := &Cache{Repo: testRepo, Key: "pending", Version: "v", UsedAt: time.Now().Add(-time.Second).Unix()}
handler := newTestHandler(t, Policy{}, fresh, pending)
txID := func() int {
db, err := handler.openDB()
require.NoError(t, err)
defer db.Close()
var id int
require.NoError(t, db.Bolt().View(func(tx *bbolt.Tx) error { id = tx.ID(); return nil }))
return id
}
before := txID()
require.NoError(t, handler.touchCache(fresh.ID, false))
assert.Equal(t, before, txID())
require.NoError(t, handler.touchCache(pending.ID, false))
assert.Greater(t, txID(), before)
}
// newTestHandler starts a handler with testToken registered, seeded with entries. // newTestHandler starts a handler with testToken registered, seeded with entries.
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler { func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
t.Helper() t.Helper()
+21 -85
View File
@@ -325,33 +325,6 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
return r, false, nil return r, false, nil
} }
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
fetchOptions.Force = true
pullOptions.Force = true
if token != "" {
auth := &http.BasicAuth{
Username: "token",
Password: token,
}
fetchOptions.Auth = auth
pullOptions.Auth = auth
}
return fetchOptions, pullOptions
}
// staleRefreshErr reports why a failed refresh must abort: the resolve and
// checkout that follow are local and succeed on a cancelled context, which
// would hand back the cached revision as if it were fresh.
func staleRefreshErr(ctx context.Context, err error) error {
if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
return nil
}
return ctx.Err()
}
// NewGitCloneExecutor creates an executor to clone git repos // NewGitCloneExecutor creates an executor to clone git repos
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
@@ -373,18 +346,21 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
isOfflineMode := input.OfflineMode isOfflineMode := input.OfflineMode
// fetch latest changes fetchOptions := git.FetchOptions{
fetchOptions, pullOptions := gitOptions(input.Token) RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
Force: true,
if input.InsecureSkipTLS { // For Gitea InsecureSkipTLS: input.InsecureSkipTLS,
fetchOptions.InsecureSkipTLS = true }
pullOptions.InsecureSkipTLS = true if input.Token != "" {
fetchOptions.Auth = &http.BasicAuth{
Username: "token",
Password: input.Token,
}
} }
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth // Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone. // Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
shallow := isShallow(r) if isShallow(r) {
if shallow {
fetchOptions.Depth = 1 fetchOptions.Depth = 1
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok { if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
fetchOptions.RefSpecs = []config.RefSpec{spec} fetchOptions.RefSpecs = []config.RefSpec{spec}
@@ -392,7 +368,9 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves. // A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
_, _, present := refRevision(r, input.Ref) // TODO: revalidate a mutable ref with a conditional archive request instead, once every
// supported Gitea sends an ETag for them: https://github.com/go-gitea/gitea/pull/39289
_, present := refRevision(r, input.Ref)
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref))) refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
if refresh { if refresh {
@@ -415,7 +393,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
} }
rev, refType, _ := refRevision(r, input.Ref) rev, _ = refRevision(r, input.Ref)
if hash, err = r.ResolveRevision(rev); err != nil { if hash, err = r.ResolveRevision(rev); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err) logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
@@ -427,47 +405,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return err return err
} }
// If the hash resolved doesn't match the ref provided in a workflow then we're
// using a branch or tag ref, not a sha
//
// Repos on disk point to commit hashes, and need to checkout input.Ref before
// we try and pull down any changes
if hash.String() != input.Ref && refType == "branch" {
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
if err = w.Checkout(&git.CheckoutOptions{
Branch: sourceRef,
Force: true,
}); err != nil {
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
return err
}
}
reusedMsg := "" reusedMsg := ""
if isOfflineMode && reused {
switch {
case refresh && !shallow:
// In shallow mode the depth-limited fetch above already advanced the ref.
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
logger.Debugf("Unable to pull %s: %v", refName, err)
}
if err := staleRefreshErr(ctx, err); err != nil {
return err
}
case isOfflineMode && reused:
reusedMsg = " (reused in offline mode)" reusedMsg = " (reused in offline mode)"
} }
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg) logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
if hash.String() != input.Ref && refType == "branch" {
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
if hash, err = r.ResolveRevision(rev); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
return err
}
}
if err = w.Checkout(&git.CheckoutOptions{ if err = w.Checkout(&git.CheckoutOptions{
Hash: *hash, Hash: *hash,
Force: true, Force: true,
@@ -476,14 +420,6 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return err return err
} }
if err = w.Reset(&git.ResetOptions{
Mode: git.HardReset,
Commit: *hash,
}); err != nil {
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
return err
}
logger.Debugf("Checked out %s", input.Ref) logger.Debugf("Checked out %s", input.Ref)
return nil return nil
} }
@@ -562,22 +498,22 @@ func pinnedRefSpec(sha string) config.RefSpec {
} }
// refRevision picks the revision to check out and reports whether it resolves locally. // refRevision picks the revision to check out and reports whether it resolves locally.
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) { func refRevision(r *git.Repository, ref string) (plumbing.Revision, bool) {
if plumbing.IsHash(ref) { if plumbing.IsHash(ref) {
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself. // git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
_, err := r.CommitObject(plumbing.NewHash(ref)) _, err := r.CommitObject(plumbing.NewHash(ref))
return plumbing.Revision(ref), "sha", err == nil return plumbing.Revision(ref), err == nil
} }
if _, err := r.Tag(ref); err == nil { if _, err := r.Tag(ref); err == nil {
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true return plumbing.Revision(path.Join("refs", "tags", ref)), true
} }
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref)) remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
if _, err := r.Reference(remoteRef, false); err == nil { if _, err := r.Reference(remoteRef, false); err == nil {
return plumbing.Revision(remoteRef), "branch", true return plumbing.Revision(remoteRef), true
} }
rev := plumbing.Revision(ref) rev := plumbing.Revision(ref)
_, err := r.ResolveRevision(rev) _, err := r.ResolveRevision(rev)
return rev, "sha", err == nil return rev, err == nil
} }
// isShallow reports whether the local repository was cloned with a limited depth. // isShallow reports whether the local repository was cloned with a limited depth.
+34 -23
View File
@@ -6,7 +6,6 @@ package git
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -524,30 +523,51 @@ func TestGitCloneExecutorShallow(t *testing.T) {
}) })
} }
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) { func TestGitCloneExecutorTransportSessions(t *testing.T) {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir() workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir)) require.NoError(t, gitCmd("init", "--initial-branch=main", workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1")) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v1")) require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "tag", "-a", "v2", "-m", "v2"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
for name, tt := range map[string]struct { for name, tt := range map[string]struct {
Ref string Ref string
Depth int Depth int
}{ }{
"shallow branch": {"main", 1}, "shallow branch": {"main", 1},
"full clone tag": {"v1", 0}, "full clone branch": {"main", 0},
"full lightweight tag": {"v1", 0},
"full annotated tag": {"v2", 0},
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
counter := installCountingTransport(t) counter := installCountingTransport(t)
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ dir := t.TempDir()
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth, clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
})(t.Context())) URL: workDir, Ref: tt.Ref, Dir: dir, Depth: tt.Depth,
assert.Equal(t, int64(1), counter.sessions.Load()) })
require.NoError(t, clone(t.Context()))
assert.Equal(t, int64(1), counter.sessions.Swap(0), "cold clone")
assert.Equal(t, gitRevParse(t, workDir, tt.Ref+"^{commit}"), gitRevParse(t, dir, "HEAD"))
require.NoError(t, clone(t.Context()))
assert.Equal(t, int64(1), counter.sessions.Swap(0), "unchanged warm cache")
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.yml"), []byte(name), 0o644))
require.NoError(t, gitCmd("-C", workDir, "add", "action.yml"))
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", name))
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v1"))
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "-a", "v2", "-m", "v2"))
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("staged"), 0o644))
require.NoError(t, gitCmd("-C", dir, "add", "action.yml"))
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("unstaged"), 0o644))
require.NoError(t, clone(t.Context()))
assert.Equal(t, int64(1), counter.sessions.Load(), "updated warm cache")
assert.Equal(t, gitRevParse(t, workDir, "HEAD"), gitRevParse(t, dir, "HEAD"))
status, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
require.NoError(t, err)
assert.Empty(t, string(status))
}) })
} }
} }
@@ -736,12 +756,3 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
t.Fatal("fetch ignored context cancellation") t.Fatal("fetch ignored context cancellation")
} }
} }
func TestStaleRefreshErr(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
require.NoError(t, staleRefreshErr(ctx, errors.New("remote hung up")))
cancel()
require.ErrorIs(t, staleRefreshErr(ctx, errors.New("remote hung up")), context.Canceled)
require.NoError(t, staleRefreshErr(ctx, gogit.NoErrAlreadyUpToDate))
}
+6
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"sync" "sync"
"sync/atomic"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -82,6 +83,11 @@ type DockerProxy struct {
close func(context.Context) error close func(context.Context) error
closeOnce sync.Once closeOnce sync.Once
closeErr error closeErr error
mounts atomic.Value
}
func (p *DockerProxy) SetMounts(mounts map[string]string) {
p.mounts.Store(mounts)
} }
func (p *DockerProxy) Close(ctx context.Context) error { func (p *DockerProxy) Close(ctx context.Context) error {
+45
View File
@@ -10,6 +10,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/netip"
"strings" "strings"
"time" "time"
@@ -172,3 +173,47 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
return errors.Join(errs...) return errors.Join(errs...)
} }
} }
func IsolatedNetwork(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) {
cli, err := GetDockerClient(ctx)
if err != nil {
return "", err
}
defer cli.Close()
return isolatedNetwork(ctx, cli, addr, jobNetwork)
}
func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) {
if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") {
return "", nil
}
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{})
if err != nil {
return "", err
}
var holderID string
for _, summary := range containers.Items {
if summary.NetworkSettings == nil {
continue
}
for _, endpoint := range summary.NetworkSettings.Networks {
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
holderID = endpoint.NetworkID
}
}
}
if holderID == "" {
return "", nil
}
holder, err := cli.NetworkInspect(ctx, holderID, client.NetworkInspectOptions{})
if err != nil || holder.Network.Driver != "bridge" {
return "", err
}
if jobNetwork != "" {
job, err := cli.NetworkInspect(ctx, jobNetwork, client.NetworkInspectOptions{})
if err != nil || job.Network.ID == holderID {
return "", err
}
}
return holder.Network.Name, nil
}
+34
View File
@@ -6,16 +6,50 @@ package container
import ( import (
"context" "context"
"errors" "errors"
"net/netip"
"testing" "testing"
"time" "time"
cerrdefs "github.com/containerd/errdefs" cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network" "github.com/moby/moby/api/types/network"
mobyclient "github.com/moby/moby/client" mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestIsolatedNetwork(t *testing.T) {
ctx := context.Background()
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
client := &mockDockerClient{}
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{
"compose": {NetworkID: "c0ffee", IPAddress: cache},
"lan": {NetworkID: "beef", IPAddress: lan},
}},
}}}, nil)
for ref, inspected := range map[string]network.Network{
"c0ffee": {ID: "c0ffee", Name: "compose", Driver: "bridge"},
"beef": {ID: "beef", Name: "lan", Driver: "macvlan"},
"c0f": {ID: "c0ffee"},
"jobs": {ID: "f00d"},
} {
client.On("NetworkInspect", ctx, ref, mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
}
check := func(addr netip.Addr, jobNetwork, want string) {
got, err := isolatedNetwork(ctx, client, addr, jobNetwork)
require.NoError(t, err)
assert.Equal(t, want, got, jobNetwork)
}
check(cache, "", "compose")
check(cache, "jobs", "compose")
check(cache, "c0f", "")
check(cache, "host", "")
check(lan, "", "")
check(netip.MustParseAddr("10.0.0.5"), "", "")
}
func TestIsAddressPoolExhausted(t *testing.T) { func TestIsAddressPoolExhausted(t *testing.T) {
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted"))) assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network"))) assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
+198 -11
View File
@@ -19,9 +19,11 @@ import (
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os" "os"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -69,13 +71,19 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err) common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil return nil
} }
seen, err := daemonSeesDir(probeCtx, cli, dir) daemonDir := dir
seen, err := daemonSeesDir(probeCtx, cli, dir, daemonDir)
if err == nil && !seen {
if dir, daemonDir = runnerContainerWorkdir(probeCtx, cli); daemonDir != "" {
seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
}
}
if err != nil { if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err) common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil return nil
} }
if !seen { if !seen {
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary filesystem, jobs get the daemon socket directly") common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
return nil return nil
} }
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -84,13 +92,35 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
proxy, err := StartDockerProxy(daemonSocket, dir, job) proxy, err := StartDockerProxy(daemonSocket, dir, job)
if err != nil { if err != nil {
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err) common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
return nil
} }
proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir)
return proxy return proxy
} }
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir, // runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory.
func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) {
workdir, err := os.Getwd()
hostname, hostnameErr := os.Hostname()
if err != nil || hostnameErr != nil {
return "", ""
}
self, err := cli.ContainerInspect(ctx, hostname, client.ContainerInspectOptions{})
if err != nil {
return "", ""
}
destination := ""
for _, point := range self.Container.Mounts {
if rel, err := filepath.Rel(point.Destination, workdir); err == nil && filepath.IsLocal(rel) && len(point.Destination) > len(destination) {
destination, daemonDir = point.Destination, filepath.Join(point.Source, rel)
}
}
return workdir, daemonDir
}
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir by their path in daemonDir,
// which is what a job's proxy socket mounted from there needs. // which is what a job's proxy socket mounted from there needs.
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool, error) { func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir string) (bool, error) {
marker, err := os.CreateTemp(dir, "gitea-runner-probe-") marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
if err != nil { if err != nil {
return false, err return false, err
@@ -113,7 +143,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool,
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{ created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}}, Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
HostConfig: &container.HostConfig{Mounts: []mount.Mount{ HostConfig: &container.HostConfig{Mounts: []mount.Mount{
{Type: mount.TypeBind, Source: marker.Name(), Target: "/gitea-runner-probe", ReadOnly: true}, {Type: mount.TypeBind, Source: filepath.Join(daemonDir, filepath.Base(marker.Name())), Target: "/gitea-runner-probe", ReadOnly: true},
}}, }},
}) })
if cerrdefs.IsInvalidArgument(err) { if cerrdefs.IsInvalidArgument(err) {
@@ -151,7 +181,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
if err != nil { if err != nil {
return nil, errors.Join(err, os.RemoveAll(instance)) return nil, errors.Join(err, os.RemoveAll(instance))
} }
if err := copyDockerSocketPermissions(socket, info); err != nil { if err := copyDockerSocketPermissions(daemonSocket, socket, info); err != nil {
return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance)) return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
} }
dial := func(ctx context.Context, _, _ string) (net.Conn, error) { dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -165,6 +195,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
}, },
Transport: transport, Transport: transport,
} }
proxy := &DockerProxy{Socket: socket}
streams, cancelStreams := context.WithCancel(context.Background()) streams, cancelStreams := context.WithCancel(context.Background())
creates, cancelCreates := context.WithCancel(context.Background()) creates, cancelCreates := context.WithCancel(context.Background())
var admission sync.Mutex var admission sync.Mutex
@@ -200,7 +231,8 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
r = r.WithContext(ctx) r = r.WithContext(ctx)
if creating { if creating {
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody) r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
if err := addLabel(r, job); err != nil { mounts, _ := proxy.mounts.Load().(map[string]string)
if err := rewriteCreate(r, job, mounts); err != nil {
status := http.StatusBadRequest status := http.StatusBadRequest
if _, ok := errors.AsType[*http.MaxBytesError](err); ok { if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
status = http.StatusRequestEntityTooLarge status = http.StatusRequestEntityTooLarge
@@ -219,7 +251,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
defer close(served) defer close(served)
_ = server.Serve(listener) _ = server.Serve(listener)
}() }()
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error { proxy.close = func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
admission.Lock() admission.Lock()
@@ -233,10 +265,11 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
handlers.Wait() handlers.Wait()
transport.CloseIdleConnections() transport.CloseIdleConnections()
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance)) return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
}}, nil }
return proxy, nil
} }
func addLabel(r *http.Request, job string) error { func rewriteCreate(r *http.Request, job string, mounts map[string]string) error {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
return err return err
@@ -255,6 +288,9 @@ func addLabel(r *http.Request, job string) error {
if fields == nil { if fields == nil {
fields = make(map[string]json.RawMessage) fields = make(map[string]json.RawMessage)
} }
if len(mounts) > 0 && !hasAmbiguousFields(body) {
translateBinds(fields, createPath.FindStringSubmatch(r.URL.Path)[2], mounts)
}
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool { maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
return strings.EqualFold(name, "Labels") return strings.EqualFold(name, "Labels")
}) })
@@ -274,6 +310,155 @@ func addLabel(r *http.Request, job string) error {
return nil return nil
} }
func translateBinds(fields map[string]json.RawMessage, kind string, mounts map[string]string) {
cleanedSource := func(source string) string {
cleaned := path.Clean(source)
if target := jobMount(cleaned, mounts); mounts[target] != "" {
return mounts[target] + cleaned[len(target):]
}
return source
}
spelledSource := func(source string) string { // dockerd checks these as spelled
target := jobMount(path.Clean(source), mounts)
if rest, spelled := strings.CutPrefix(source, target); mounts[target] != "" && spelled && (rest == "" || rest[0] == '/') && filepath.IsLocal("."+rest) {
return mounts[target] + rest
}
return cleanedSource(source)
}
switch kind {
case "volumes":
var driver string
var options map[string]any
decodeField(fields, "Driver", &driver)
if key := decodeField(fields, "DriverOpts", &options); options != nil {
translateDevice(driver, options, spelledSource)
if encoded, err := json.Marshal(options); err == nil {
fields[key] = encoded
}
}
case "containers":
var hostConfig map[string]any
key := decodeField(fields, "HostConfig", &hostConfig)
binds, _ := field(hostConfig, "Binds").([]any)
for i, bind := range binds {
bind, _ := bind.(string)
source, target, _ := strings.Cut(bind, ":")
if translated := cleanedSource(source); strings.HasPrefix(target, "/") && !strings.Contains(translated, ":") {
binds[i] = translated + ":" + target
}
}
specs, _ := field(hostConfig, "Mounts").([]any)
for _, spec := range specs {
spec, _ := spec.(map[string]any)
switch field(spec, "Type") {
case "bind":
if source, ok := field(spec, "Source").(string); ok {
spec["Source"] = spelledSource(source)
}
case "volume":
volumeOptions, _ := field(spec, "VolumeOptions").(map[string]any)
driverConfig, _ := field(volumeOptions, "DriverConfig").(map[string]any)
translateDevice(field(driverConfig, "Name"), field(driverConfig, "Options"), spelledSource)
}
}
if encoded, err := json.Marshal(hostConfig); err == nil && hostConfig != nil {
fields[key] = encoded
}
}
}
func decodeField(fields map[string]json.RawMessage, name string, value any) string {
for key, raw := range fields {
if strings.EqualFold(key, name) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
_ = decoder.Decode(value) // wrong types stay unset for dockerd to reject
return key
}
}
return ""
}
func translateDevice(driver, options any, translate func(string) string) {
optionMap, _ := options.(map[string]any)
device, ok := optionMap["device"].(string)
flags, _ := optionMap["o"].(string)
tokens := strings.Split(flags, ",")
local := driver == nil || driver == "" || driver == "local"
if ok && local && (slices.Contains(tokens, "bind") || slices.Contains(tokens, "rbind")) && !slices.Contains(tokens, "remount") {
optionMap["device"] = translate(device)
}
}
// field also renames the matched key to name.
func field(object map[string]any, name string) any {
for key, value := range object {
if strings.EqualFold(key, name) {
delete(object, key)
object[name] = value
return value
}
}
return nil
}
var (
requestFields = []string{"hostconfig", "driver", "driveropts"}
asciiFolds = strings.NewReplacer("ſ", "s", "K", "k") // the non-ASCII runes strings.EqualFold matches to ASCII letters
)
// dockerd settles repeated names by order, which re-encoding loses.
func hasAmbiguousFields(body []byte) bool {
type frame struct {
names map[string]bool
key string
expectKey bool
nested bool
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
stack := []*frame{{}}
for {
token, err := decoder.Token()
if err != nil {
return !errors.Is(err, io.EOF)
}
top := stack[len(stack)-1]
if name, ok := token.(string); ok && top.expectKey {
top.key, top.expectKey = strings.ToLower(asciiFolds.Replace(name)), false
if top.names[top.key] && (top.nested || slices.Contains(requestFields, top.key)) {
return true
}
top.names[top.key] = true
continue
}
top.expectKey = top.names != nil
nested := top.nested || len(stack) == 2 && slices.Contains(requestFields, top.key)
switch token {
case json.Delim('{'):
stack = append(stack, &frame{names: map[string]bool{}, expectKey: true, nested: nested})
case json.Delim('['):
stack = append(stack, &frame{nested: nested})
case json.Delim('}'), json.Delim(']'):
stack = stack[:len(stack)-1]
}
}
}
// jobMount returns "" also for a path already naming a daemon source.
func jobMount(source string, mounts map[string]string) string {
target := ""
for destination, daemonSource := range mounts {
if daemonSource != "" && (source == daemonSource || strings.HasPrefix(source, daemonSource+"/")) {
return ""
}
if len(destination) > len(target) && (source == destination || strings.HasPrefix(source, destination+"/")) {
target = destination
}
}
return target
}
type dockerProxyConnKey struct{} type dockerProxyConnKey struct{}
type dockerProxyResponse struct { type dockerProxyResponse struct {
@@ -380,7 +565,9 @@ func removeLabelled(ctx context.Context, cli client.APIClient, job string) error
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters}) networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
errs = append(errs, err) errs = append(errs, err)
for _, n := range networks.Items { for _, n := range networks.Items {
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); n.Scope == "swarm" && cerrdefs.IsInvalidArgument(err) { // swarm refuses while a service or its tasks use it
logger.Infof("keeping network %s, a swarm service still uses it", n.Name)
} else if err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err)) errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
} }
} }
+34 -5
View File
@@ -27,6 +27,7 @@ import (
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image" "github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/volume" "github.com/moby/moby/api/types/volume"
mobyclient "github.com/moby/moby/client" mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -111,6 +112,7 @@ func TestDockerProxy(t *testing.T) {
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1") proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) }) t.Cleanup(func() { _ = proxy.Close(context.Background()) })
proxy.SetMounts(map[string]string{"/workspace/o/r": "/volumes/job/_data", "/workspace/o/r/tmp": "", "/var/run/docker.sock": "/tmp/p/docker.sock", "/volumes": "/daemon/volumes"})
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket) return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
}}} }}}
@@ -147,13 +149,37 @@ func TestDockerProxy(t *testing.T) {
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`, "/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`, `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
}, },
{
"/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`,
`{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job":"job-1"}}`,
},
{
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`,
`{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`,
`{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`,
`{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{ {
"/networks/create", `{"Name":"n"}`, "/networks/create", `{"Name":"n"}`,
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`, `{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
}, },
{ {
"/volumes/create", `{"Name":"v","labels":null}`, "/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
`{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"}}`, `{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
},
{
"/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`,
`{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`,
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
}, },
{ {
"/volumes/create", "", "/volumes/create", "",
@@ -267,7 +293,9 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once() cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}). cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once() Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).Return(mobyclient.NetworkListResult{}, listFailure).Once() cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "stack_default", Scope: "swarm"}}}, listFailure).Once()
cli.On("NetworkRemove", ctx, "n1", mobyclient.NetworkRemoveOptions{}).Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrInvalidArgument).Once()
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}). cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once() Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once() cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
@@ -276,6 +304,7 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
require.ErrorIs(t, err, containerFailure) require.ErrorIs(t, err, containerFailure)
require.ErrorIs(t, err, listFailure) require.ErrorIs(t, err, listFailure)
require.ErrorIs(t, err, volumeFailure) require.ErrorIs(t, err, volumeFailure)
require.NotErrorIs(t, err, cerrdefs.ErrInvalidArgument)
require.ErrorContains(t, err, "failed to remove container c1") require.ErrorContains(t, err, "failed to remove container c1")
require.ErrorContains(t, err, "failed to remove volume app_data") require.ErrorContains(t, err, "failed to remove volume app_data")
cli.AssertExpectations(t) cli.AssertExpectations(t)
@@ -289,7 +318,7 @@ func TestDockerProxyWithDaemon(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer direct.Close() defer direct.Close()
dir := shortTempDir(t) dir := shortTempDir(t)
seen, err := daemonSeesDir(ctx, direct, dir) seen, err := daemonSeesDir(ctx, direct, dir, dir)
require.NoError(t, err) require.NoError(t, err)
t.Logf("daemon sees the runner's filesystem: %v", seen) t.Logf("daemon sees the runner's filesystem: %v", seen)
@@ -457,7 +486,7 @@ func TestDaemonSeesDir(t *testing.T) {
return mobyclient.ContainerRemoveResult{}, testCase.removeErr return mobyclient.ContainerRemoveResult{}, testCase.removeErr
}, },
} }
seen, err := daemonSeesDir(ctx, cli, dir) seen, err := daemonSeesDir(ctx, cli, dir, dir)
require.ErrorIs(t, err, testCase.removeErr) require.ErrorIs(t, err, testCase.removeErr)
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen) assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
assert.Equal(t, !testCase.private, removed) assert.Equal(t, !testCase.private, removed)
+25 -4
View File
@@ -6,22 +6,43 @@
package container package container
import ( import (
"encoding/binary"
"errors" "errors"
"io/fs"
"math"
"os" "os"
"path/filepath" "path/filepath"
"syscall" "syscall"
"golang.org/x/sys/unix"
) )
func copyDockerSocketPermissions(socket string, info os.FileInfo) error { func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t) stat, ok := info.Sys().(*syscall.Stat_t)
if !ok { if !ok {
return errors.New("docker socket ownership is unavailable") return errors.New("docker socket ownership is unavailable")
} }
if err := os.Chown(socket, int(stat.Uid), int(stat.Gid)); err != nil { groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
return err if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) {
return groupErr
} }
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil { if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
return err return err
} }
return os.Chmod(socket, info.Mode().Perm()) mode := uint16(info.Mode().Perm())
if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil {
return err
}
if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 {
return groupErr
}
owner, group, other := mode>>6, mode>>3&7, mode&7
acl := binary.LittleEndian.AppendUint32(nil, 2)
for _, entry := range []struct {
tag, perm uint16
id uint32
}{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} {
acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id)
}
return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had
} }
+1 -1
View File
@@ -10,6 +10,6 @@ import (
"os" "os"
) )
func copyDockerSocketPermissions(_ string, _ os.FileInfo) error { func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
return errors.New("docker socket ownership cannot be preserved on Windows") return errors.New("docker socket ownership cannot be preserved on Windows")
} }
+22
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
@@ -257,6 +258,27 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
for _, mountPoint := range inspect.Mounts { for _, mountPoint := range inspect.Mounts {
info.Mounts[mountPoint.Destination] = mountPoint.Source info.Mounts[mountPoint.Destination] = mountPoint.Source
} }
if hostConfig := inspect.HostConfig; hostConfig != nil { // Mounts omits --tmpfs targets and subpaths
for target := range hostConfig.Tmpfs {
info.Mounts[path.Clean(target)] = ""
}
for _, spec := range hostConfig.Mounts {
subpath := ""
if spec.VolumeOptions != nil {
subpath = spec.VolumeOptions.Subpath
} else if spec.ImageOptions != nil {
subpath = spec.ImageOptions.Subpath
}
if target := path.Clean(spec.Target); subpath != "" && info.Mounts[target] != "" {
info.Mounts[target] = path.Join(info.Mounts[target], subpath)
}
}
}
for _, target := range []string{"/etc/hosts", "/etc/hostname", "/etc/resolv.conf"} { // specific to the job's network namespace
if _, mounted := info.Mounts[target]; !mounted {
info.Mounts[target] = ""
}
}
if state := inspect.State; state != nil { if state := inspect.State; state != nil {
info.State = string(state.Status) info.State = string(state.Status)
+13 -2
View File
@@ -814,12 +814,23 @@ func TestContainerInfoFromInspect(t *testing.T) {
Mounts: []container.MountPoint{ Mounts: []container.MountPoint{
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"}, {Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"}, {Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
{Type: mount.TypeVolume, Name: "cache", Source: "/var/lib/docker/volumes/cache/_data", Destination: "/cache"},
{Type: mount.TypeBind, Source: "/custom/resolv.conf", Destination: "/etc/resolv.conf"},
},
HostConfig: &container.HostConfig{
Tmpfs: map[string]string{"/workspace/owner/repo//tmp/": "size=1m"},
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "cache", Target: "/cache/", VolumeOptions: &mount.VolumeOptions{Subpath: "project"}}},
}, },
}) })
assert.Equal(t, map[string]string{ assert.Equal(t, map[string]string{
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data", "/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
"/var/run/docker.sock": "/var/run/docker.sock", "/workspace/owner/repo/tmp": "",
"/var/run/docker.sock": "/var/run/docker.sock",
"/cache": "/var/lib/docker/volumes/cache/_data/project",
"/etc/resolv.conf": "/custom/resolv.conf",
"/etc/hosts": "",
"/etc/hostname": "",
}, info.Mounts) }, info.Mounts)
}) })
} }
+13
View File
@@ -9,6 +9,7 @@ package container
import ( import (
"context" "context"
"errors" "errors"
"net/netip"
"runtime" "runtime"
"time" "time"
@@ -17,6 +18,10 @@ import (
"github.com/moby/moby/api/types/system" "github.com/moby/moby/api/types/system"
) )
func IsolatedNetwork(context.Context, netip.Addr, string) (string, error) {
return "", nil
}
// ImageExistsLocally returns a boolean indicating if an image with the // ImageExistsLocally returns a boolean indicating if an image with the
// requested name, tag and architecture exists in the local docker image store // requested name, tag and architecture exists in the local docker image store
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) { func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
@@ -89,3 +94,11 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error { func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
return nil return nil
} }
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
return nil
}
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
return nil
}
+55 -25
View File
@@ -8,40 +8,67 @@ package container
import ( import (
"context" "context"
"errors"
"fmt"
"time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/client" "github.com/moby/moby/client"
) )
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor { func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
return func(ctx context.Context) error { cli, err := GetDockerClient(ctx)
cli, err := GetDockerClient(ctx) if err != nil {
if err != nil { return err
return err
}
defer cli.Close()
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
if err != nil {
return err
}
for _, vol := range list.Items {
if vol.Name == volumeName {
return removeExecutor(volumeName, force)(ctx)
}
}
// Volume not found - do nothing
return nil
} }
defer cli.Close()
for _, volumeName := range volumeNames {
if _, err := cli.VolumeCreate(ctx, client.VolumeCreateOptions{
Name: volumeName,
Labels: runnerLabels(runnerUUID),
}); err != nil {
return err
}
}
return nil
} }
func removeExecutor(volume string, force bool) common.Executor { func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
}
defer cli.Close()
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID).Add("dangling", "true"),
})
if err != nil {
return err
}
var errs []error
for _, item := range volumes.Items {
created, err := time.Parse(time.RFC3339, item.CreatedAt)
// an unreadable or recent timestamp may belong to a job that is still starting up
if err != nil || created.After(createdBefore) || item.Labels[runnerUUIDLabel] != runnerUUID {
continue
}
if _, err := cli.VolumeRemove(ctx, item.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", item.Name, err))
continue
}
common.Logger(ctx).Infof("removed docker volume %s left behind by an earlier job", item.Name)
}
return errors.Join(errs...)
}
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
logger.Debugf("docker volume rm %s", volume)
if common.Dryrun(ctx) { if common.Dryrun(ctx) {
return nil return nil
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
} }
defer cli.Close() defer cli.Close()
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force}) _, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
return nil
}
return err return err
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@ import (
func TestCompositeActionParity(t *testing.T) { func TestCompositeActionParity(t *testing.T) {
t.Run("inherits contexts without leaking inputs", func(t *testing.T) { t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
ctx := t.Context() ctx := t.Context()
strategy := &model.Strategy{MaxParallel: 3} strategy := &model.Strategy{MaxParallelString: "3"}
parent := &RunContext{ parent := &RunContext{
Config: &Config{}, Config: &Config{},
Matrix: map[string]any{"os": "linux"}, Matrix: map[string]any{"os": "linux"},
+9 -1
View File
@@ -49,12 +49,17 @@ func TestDockerProxyMountedJob(t *testing.T) {
ContainerNamePrefix: resourceName, ContainerNamePrefix: resourceName,
ContainerDaemonSocket: dockerClient.DaemonHost(), ContainerDaemonSocket: dockerClient.DaemonHost(),
ContainerMaxLifetime: 2 * time.Minute, ContainerMaxLifetime: 2 * time.Minute,
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode}, ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
}) })
require.NoError(t, err) require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true) planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
require.NoError(t, err) require.NoError(t, err)
plan, err := planner.PlanEvent("push") plan, err := planner.PlanEvent("push")
if mode == "direct" {
plan, err = planner.PlanJob("proxy")
}
require.NoError(t, err) require.NoError(t, err)
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil) runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
require.NoError(t, err) require.NoError(t, err)
@@ -85,6 +90,9 @@ func TestDockerProxyMountedJob(t *testing.T) {
messages = append(messages, strings.TrimSpace(entry.Message)) messages = append(messages, strings.TrimSpace(entry.Message))
} }
require.Contains(t, messages, "docker proxy post verified") require.Contains(t, messages, "docker proxy post verified")
if mode == "proxy" {
require.Contains(t, messages, "docker binds verified")
}
_, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{}) _, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err) assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{}) _, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
+30 -5
View File
@@ -27,6 +27,29 @@ import (
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
// setStrategyContext leaves `max-parallel` unset when the job declares none, as GitHub does.
func setStrategyContext(strategy map[string]any, jobStrategy *model.Strategy) {
strategy["fail-fast"] = jobStrategy.GetFailFast()
if limit, declared, err := jobStrategy.ParseMaxParallel(); declared && err == nil {
strategy["max-parallel"] = limit
}
}
// decodeDeferred decodes a whole-value `${{ }}` parked in a Raw* field, evaluating a clone since the node is shared across matrix combinations.
func decodeDeferred[T any](ctx context.Context, eval *expressionEvaluator, name string, raw yaml.Node, out *T) error {
if raw.Kind != yaml.ScalarNode {
return nil
}
node := model.CloneYamlNode(raw)
if err := eval.EvaluateYamlNode(ctx, &node); err != nil {
return fmt.Errorf("unable to evaluate %s: %w", name, err)
}
if err := node.Decode(out); err != nil {
return fmt.Errorf("unable to decode %s: %w", name, err)
}
return nil
}
// NewExpressionEvaluator creates a new evaluator // NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator { func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator {
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv()) return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
@@ -41,8 +64,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
if rc.Run != nil { if rc.Run != nil {
job := rc.Run.Job() job := rc.Run.Job()
if job != nil && job.Strategy != nil { if job != nil && job.Strategy != nil {
strategy["fail-fast"] = job.Strategy.FailFast setStrategyContext(strategy, job.Strategy)
strategy["max-parallel"] = job.Strategy.MaxParallel
} }
jobs := rc.Run.Workflow.Jobs jobs := rc.Run.Workflow.Jobs
@@ -118,8 +140,7 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
job := rc.Run.Job() job := rc.Run.Job()
strategy := make(map[string]any) strategy := make(map[string]any)
if job.Strategy != nil { if job.Strategy != nil {
strategy["fail-fast"] = job.Strategy.FailFast setStrategyContext(strategy, job.Strategy)
strategy["max-parallel"] = job.Strategy.MaxParallel
} }
jobs := rc.Run.Workflow.Jobs jobs := rc.Run.Workflow.Jobs
@@ -333,12 +354,16 @@ func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
} }
callerJob := rc.caller.runContext.Run.Job() callerJob := rc.caller.runContext.Run.Job()
callerEval := rc.caller.runContext.ExprEval callerEval := rc.caller.runContext.ExprEval
callerWith := callerJob.With
if err := decodeDeferred(ctx, callerEval, "workflow inputs", callerJob.RawWith, &callerWith); err != nil {
return err
}
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) }) calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
config := rc.Run.Workflow.WorkflowCallConfig() config := rc.Run.Workflow.WorkflowCallConfig()
rc.workflowCallInputs = make(map[string]any, len(config.Inputs)) rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
for name, input := range config.Inputs { for name, input := range config.Inputs {
value, eval, label := callerJob.With[name], callerEval, "input" value, eval, label := callerWith[name], callerEval, "input"
if value == nil { if value == nil {
value, eval, label = input.Default, calleeEval(), "the default of input" value, eval, label = input.Default, calleeEval(), "the default of input"
} }
+28 -3
View File
@@ -89,7 +89,7 @@ func TestEvaluateRunContext(t *testing.T) {
out any out any
errMesg string errMesg string
}{ }{
{" 1 ", 1, ""}, {" 1 ", 1.0, ""},
// {"1 + 3", "4", ""}, // {"1 + 3", "4", ""},
// {"(1 + 3) * -2", "-8", ""}, // {"(1 + 3) * -2", "-8", ""},
{"'my text'", "my text", ""}, {"'my text'", "my text", ""},
@@ -266,7 +266,6 @@ func TestInterpolate(t *testing.T) {
{"${{ null }}", ""}, {"${{ null }}", ""},
{"${{ fromJSON('[1,2]') }}", "Array"}, {"${{ fromJSON('[1,2]') }}", "Array"},
{"${{ fromJSON('{\"a\":1}') }}", "Object"}, {"${{ fromJSON('{\"a\":1}') }}", "Object"},
{"${{ 1", "${{ 1"},
} }
for _, table := range tables { for _, table := range tables {
@@ -277,7 +276,7 @@ func TestInterpolate(t *testing.T) {
}) })
} }
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} { for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
_, err := ee.Interpolate(context.Background(), in) _, err := ee.Interpolate(context.Background(), in)
assert.Error(t, err, in) assert.Error(t, err, in)
} }
@@ -360,6 +359,32 @@ on:
}) })
} }
} }
t.Run("deferred workflow call inputs", func(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(workflows["workflow_call"] + `
jobs:
test: {}
call:
uses: ./reuse.yml
with: ${{ fromJSON(matrix.args) }}
`))
require.NoError(t, err)
workflow.GetJob("test")
job := workflow.GetJob("call")
rawWith := model.CloneYamlNode(job.RawWith)
for _, value := range []string{"true", "false"} {
t.Run(value, func(t *testing.T) {
t.Parallel()
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"runner"}`})
require.NoError(t, err)
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err)
assert.Equal(t, map[string]any{"flag": value == "true", "name": "runner"}, child.workflowCallInputs)
assert.Equal(t, rawWith, job.RawWith)
assert.Nil(t, job.With)
})
}
})
} }
func TestJobNameMasksSecrets(t *testing.T) { func TestJobNameMasksSecrets(t *testing.T) {
+8
View File
@@ -417,6 +417,14 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup. // evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error { func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
var workflowEnv map[string]string
if err := decodeDeferred(ctx, rc.ExprEval, "workflow env", rc.Run.Workflow.RawEnv, &workflowEnv); err != nil {
return err
}
if workflowEnv != nil {
rc.Env = mergeMaps(workflowEnv, rc.GetEnv())
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
}
var err error var err error
for k, v := range rc.GetEnv() { for k, v := range rc.GetEnv() {
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil { if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
+40 -3
View File
@@ -4,6 +4,7 @@
package runner package runner
import ( import (
"strings"
"testing" "testing"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
@@ -34,9 +35,14 @@ func TestMaxParallelStrategy(t *testing.T) {
expectedMaxParallel: 4, expectedMaxParallel: 4,
}, },
{ {
name: "max-parallel-10", name: "max-parallel-10-clamped-to-combinations",
maxParallelString: "10", maxParallelString: "10",
expectedMaxParallel: 10, expectedMaxParallel: 5,
},
{
name: "max-parallel-invalid-falls-back-to-default",
maxParallelString: "tow",
expectedMaxParallel: 4,
}, },
} }
@@ -61,9 +67,29 @@ func TestMaxParallelStrategy(t *testing.T) {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, matrixes) assert.NotNil(t, matrixes)
assert.Len(t, matrixes, 5) assert.Len(t, matrixes, 5)
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel) assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes)))
}) })
} }
t.Run("deferred strategy", func(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
jobs:
test:
if: false
strategy: ${{ fromJSON('{"max-parallel":2,"fail-fast":false,"matrix":{"os":["ubuntu","windows"]}}') }}
`))
require.NoError(t, err)
job := workflow.Jobs["test"]
rawStrategy := model.CloneYamlNode(job.RawStrategy)
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
require.NotNil(t, job.Strategy)
assert.False(t, job.Strategy.GetFailFast())
assert.Equal(t, 2, maxParallelFor(job.Strategy, 5))
matrixes, err := job.GetMatrixes()
require.NoError(t, err)
assert.Equal(t, []map[string]any{{"os": "ubuntu"}, {"os": "windows"}}, matrixes)
assert.Equal(t, rawStrategy, job.RawStrategy)
})
} }
func TestNewPlanExecutorInvalidMatrix(t *testing.T) { func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
@@ -81,4 +107,15 @@ func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
runner := &runnerImpl{config: &Config{}} runner := &runnerImpl{config: &Config{}}
require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:") require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:")
for _, strategy := range []string{
`${{ fromJSON('invalid') }}`,
`${{ '${{ inputs.unresolved }}' }}`,
} {
t.Run(strategy, func(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n test:\n strategy: " + strategy))
require.NoError(t, err)
require.Error(t, runner.NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
})
}
} }
+91 -28
View File
@@ -18,6 +18,7 @@ import (
maps0 "maps" maps0 "maps"
"net" "net"
"os" "os"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
@@ -68,6 +69,7 @@ type RunContext struct {
actionInputs map[string]any // inputs of the composite action this runs, nil for a job actionInputs map[string]any // inputs of the composite action this runs, nil for a job
Masks []string Masks []string
cleanUpJobContainer common.Executor cleanUpJobContainer common.Executor
deferVolumeCleanup func(common.Executor)
caller *caller // job calling this RunContext (reusable workflows) caller *caller // job calling this RunContext (reusable workflows)
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
@@ -98,6 +100,7 @@ type RunContext struct {
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
jobNetworkName string jobNetworkName string
dockerProxy *container.DockerProxy dockerProxy *container.DockerProxy
hadDockerProxy bool
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out // stepEnv is a copy of the running step's environment, so that workflow commands parsed out
// of the container's output can be judged against it. Written by runStepExecutor and read on // of the container's output can be judged against it. Written by runStepExecutor and read on
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr. // the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
@@ -253,7 +256,7 @@ func (rc *RunContext) validVolumes() []string {
volumes = append(volumes, sharedToolCacheVolume) volumes = append(volumes, sharedToolCacheVolume)
} }
if rc.Config.BindWorkdir { if rc.Config.BindWorkdir {
volumes = append(volumes, rc.Config.Workdir) volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
} }
if rc.dockerProxy != nil { if rc.dockerProxy != nil {
volumes = append(volumes, rc.dockerProxy.Socket) volumes = append(volumes, rc.dockerProxy.Socket)
@@ -263,6 +266,13 @@ func (rc *RunContext) validVolumes() []string {
getDockerDaemonSocketMountPath(rc.containerDaemonSocket())) getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
} }
func (rc *RunContext) workdirMountRoot() string {
if rc.Config.PresetGitHubContext != nil {
return filepath.Dir(filepath.Dir(rc.Config.Workdir)) // only the daemon presets, its workdir is <parent>/<owner>/<repo>
}
return rc.Config.Workdir
}
func (rc *RunContext) jobDockerSocket() string { func (rc *RunContext) jobDockerSocket() string {
if rc.dockerProxy != nil { if rc.dockerProxy != nil {
return rc.dockerProxy.Socket return rc.dockerProxy.Socket
@@ -357,6 +367,11 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] { if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
source := rc.workdirMountRoot()
target := ext.ToContainerPath(source)
if claimed[target] {
source, target = rc.Config.Workdir, workdir
}
if rc.Config.BindWorkdir { if rc.Config.BindWorkdir {
bindModifiers := "" bindModifiers := ""
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
@@ -365,9 +380,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
if selinux.GetEnabled() { if selinux.GetEnabled() {
bindModifiers = ":z" bindModifiers = ":z"
} }
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers)) binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers))
} else { } else {
mounts[name] = workdir mounts[name] = target
} }
} }
@@ -459,8 +474,15 @@ func printStartJobContainerGroup(ctx context.Context, image, name, network strin
// newContainer is a variable so tests can substitute a container that needs no Docker daemon. // newContainer is a variable so tests can substitute a container that needs no Docker daemon.
var newContainer = container.NewContainer var newContainer = container.NewContainer
type jobVolumeCleanupKey struct{}
func WithJobVolumeCleanup(ctx context.Context, deferCleanup func(common.Executor)) context.Context {
return context.WithValue(ctx, jobVolumeCleanupKey{}, deferCleanup)
}
func (rc *RunContext) startJobContainer() common.Executor { func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
rc.deferVolumeCleanup, _ = ctx.Value(jobVolumeCleanupKey{}).(func(common.Executor))
logger := common.Logger(ctx) logger := common.Logger(ctx)
image := rc.platformImage image := rc.platformImage
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
@@ -484,10 +506,13 @@ func (rc *RunContext) startJobContainer() common.Executor {
// if using service containers, will create a new network for the containers. // if using service containers, will create a new network for the containers.
// and it will be removed after at last. // and it will be removed after at last.
networkName, createAndDeleteNetwork := rc.networkNameForGitea() networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork) rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false)
// add service containers services := rc.Run.Job().Services
for serviceID, spec := range rc.Run.Job().Services { if err := decodeDeferred(ctx, rc.ExprEval, "job services", rc.Run.Job().RawServices, &services); err != nil {
return err
}
for serviceID, spec := range services {
// GitHub compatibility: skip services whose image evaluates to an // GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions // empty string, enabling conditional services via expressions
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image) serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
@@ -610,10 +635,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
} }
defer printStartJobContainerGroup(ctx, image, name, networkName)() defer printStartJobContainerGroup(ctx, image, name, networkName)()
if err := common.NewPipelineExecutor( if err := common.NewPipelineExecutor(
rc.stopJobContainer(), rc.cleanupJobResources(networkName, createAndDeleteNetwork, true),
rc.pullServicesImages(rc.Config.ForcePull), rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull), rc.JobContainer.Pull(rc.Config.ForcePull),
).Finally(rc.closeContainer())(ctx); err != nil { )(ctx); err != nil {
return err return err
} }
rc.startDockerProxy(ctx) rc.startDockerProxy(ctx)
@@ -629,7 +654,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
IfBool(createAndDeleteNetwork), IfBool(createAndDeleteNetwork),
rc.startServiceContainers(), rc.startServiceContainers(),
rc.reportUnstartedServices(), rc.reportUnstartedServices(),
rc.waitForServiceContainers(), func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
rc.JobContainer.Start(false), rc.JobContainer.Start(false),
rc.captureJobContainerInfo(), rc.captureJobContainerInfo(),
@@ -642,6 +667,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
Mode: 0o666, Mode: 0o666,
Body: "", Body: "",
}), }),
rc.waitForServiceContainers(),
)(ctx) )(ctx)
} }
} }
@@ -654,7 +680,7 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
}) })
} }
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor { func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
errs := []error{rc.closeDockerProxy(ctx)} errs := []error{rc.closeDockerProxy(ctx)}
@@ -665,14 +691,13 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
logger.Infof("Cleaning up services for job %s", rc.JobName) logger.Infof("Cleaning up services for job %s", rc.JobName)
errs = append(errs, rc.stopServiceContainers()(ctx)) errs = append(errs, rc.stopServiceContainers()(ctx))
} }
if !common.Dryrun(ctx) { if !common.Dryrun(ctx) && (preclean || rc.hadDockerProxy) {
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName())) errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
} }
if rc.JobContainer != nil { if preclean || rc.deferVolumeCleanup == nil {
name := rc.jobContainerName() errs = append(errs, rc.cleanupJobVolumes(ctx))
errs = append(errs, } else {
container.NewDockerVolumeRemoveExecutor(name, false)(ctx), rc.deferVolumeCleanup(rc.cleanupJobVolumes)
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
} }
if createAndDeleteNetwork { if createAndDeleteNetwork {
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName) logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
@@ -682,10 +707,34 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
} }
} }
func (rc *RunContext) createJobVolumes(ctx context.Context, mounts map[string]string) error {
runnerUUID := rc.Config.ContainerNetworkCreateOptions.RunnerUUID
if runnerUUID == "" || common.Dryrun(ctx) {
return nil
}
name := rc.jobContainerName()
volumeNames := []string{name, name + "-env"}
if _, ok := mounts[name]; !ok { // the workspace is a bind mount, the env volume is always ours
volumeNames = volumeNames[1:]
}
return container.CreateJobVolumes(ctx, runnerUUID, volumeNames)
}
func (rc *RunContext) cleanupJobVolumes(ctx context.Context) error {
if rc.JobContainer == nil {
return nil
}
name := rc.jobContainerName()
return errors.Join(
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
}
func (rc *RunContext) closeDockerProxy(ctx context.Context) error { func (rc *RunContext) closeDockerProxy(ctx context.Context) error {
if rc.dockerProxy == nil { if rc.dockerProxy == nil {
return nil return nil
} }
rc.hadDockerProxy = true
err := rc.dockerProxy.Close(ctx) err := rc.dockerProxy.Close(ctx)
rc.dockerProxy = nil rc.dockerProxy = nil
if err != nil { if err != nil {
@@ -777,7 +826,6 @@ func (rc *RunContext) startServiceContainers() common.Executor {
execs := []common.Executor{} execs := []common.Executor{}
for _, svc := range rc.serviceContainers { for _, svc := range rc.serviceContainers {
execs = append(execs, common.NewPipelineExecutor( execs = append(execs, common.NewPipelineExecutor(
svc.container.Pull(false),
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
svc.container.Start(false), svc.container.Start(false),
)) ))
@@ -803,12 +851,9 @@ func (rc *RunContext) stopServiceContainers() common.Executor {
} }
} }
const ( const defaultServiceReadyTimeout = 5 * time.Minute
defaultServiceReadyTimeout = 5 * time.Minute
serviceReadyPollMax = 32 * time.Second
)
var serviceReadyPollInterval = 2 * time.Second // a variable so tests need not wait const serviceReadyPollInterval = time.Second
// reportUnstartedServices logs a service that did not start. The steps that need it // reportUnstartedServices logs a service that did not start. The steps that need it
// report it better than the runner can, so the job carries on. // report it better than the runner can, so the job carries on.
@@ -868,7 +913,7 @@ func (rc *RunContext) waitForServiceContainers() common.Executor {
// ready at once and one that exited is left to the steps that need it. // ready at once and one that exited is left to the steps that need it.
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error { func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true) rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
interval := serviceReadyPollInterval loggedStarting := false
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
@@ -898,12 +943,14 @@ func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.
return nil return nil
} }
rawLogger.Infof("%s service is starting, waiting %d seconds before checking again.", svc.name, int(interval.Seconds())) if !loggedStarting {
rawLogger.Infof("%s service is starting.", svc.name)
loggedStarting = true
}
select { select {
case <-ctx.Done(): // reported at the top of the loop case <-ctx.Done(): // reported at the top of the loop
case <-time.After(interval): case <-time.After(serviceReadyPollInterval):
} }
interval = min(interval*2, serviceReadyPollMax)
} }
} }
@@ -945,8 +992,15 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
return nil return nil
} }
rc.jobContainerID = info.ID rc.jobContainerID = info.ID
if source := info.Mounts[rc.githubWorkspace()]; source != "" { if rc.dockerProxy != nil {
rc.Env["GITEA_DOCKER_WORKSPACE"] = source rc.dockerProxy.SetMounts(info.Mounts)
}
workspace := rc.githubWorkspace()
for dir := workspace; dir != "/" && dir != "."; dir = path.Dir(dir) {
if source := info.Mounts[dir]; source != "" {
rc.Env["GITEA_DOCKER_WORKSPACE"] = path.Join(source, strings.TrimPrefix(workspace, dir))
break
}
} }
return nil return nil
} }
@@ -991,6 +1045,15 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
// with its own resolved values (last wins, as on GitHub) instead of the first combo's // with its own resolved values (last wins, as on GitHub) instead of the first combo's
// resolved values freezing the shared template against later combos. // resolved values freezing the shared template against later combos.
// Resolved up front so one failure publishes none of them, as GitHub does. // Resolved up front so one failure publishes none of them, as GitHub does.
var deferredOutputs map[string]string
if err := decodeDeferred(ctx, ee, "job outputs", job.RawOutputs, &deferredOutputs); err != nil {
return err
}
if deferredOutputs != nil {
defer lockJob(job)()
job.Outputs = deferredOutputs
return nil
}
outputs := make(map[string]string, len(rc.outputTemplate)) outputs := make(map[string]string, len(rc.outputTemplate))
var err error var err error
for k, v := range rc.outputTemplate { for k, v := range rc.outputTemplate {
+306 -88
View File
@@ -7,15 +7,21 @@ package runner
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"runtime" "runtime"
"slices"
"strings" "strings"
"testing" "testing"
"testing/synctest"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -24,6 +30,7 @@ import (
"gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/moby/moby/api/types/volume"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -240,8 +247,20 @@ func (fakeContainer) Inspect(context.Context) (*container.Info, error) {
func (fakeContainer) DumpLogs(context.Context) error { return nil } func (fakeContainer) DumpLogs(context.Context) error { return nil }
// startJobContainerInputs runs startJobContainer against fakeContainer and returns the func fakeDockerDaemon(t *testing.T, handler http.HandlerFunc) {
// inputs it built, one per container. t.Helper()
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(request.URL.Path, "/_ping") {
writer.Header().Set("API-Version", "1.47")
} else {
handler(writer, request)
}
}))
t.Cleanup(daemon.Close)
t.Setenv("DOCKER_HOST", daemon.URL)
}
func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*container.NewContainerInput { func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*container.NewContainerInput {
t.Helper() t.Helper()
workflow, err := model.ReadWorkflow(strings.NewReader(workflowYAML)) workflow, err := model.ReadWorkflow(strings.NewReader(workflowYAML))
@@ -282,6 +301,8 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
_, _ = io.WriteString(w, "[]") _, _ = io.WriteString(w, "[]")
case strings.HasSuffix(r.URL.Path, "/volumes"): case strings.HasSuffix(r.URL.Path, "/volumes"):
_, _ = io.WriteString(w, `{"Volumes":[]}`) _, _ = io.WriteString(w, `{"Volumes":[]}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/volumes/"):
w.WriteHeader(http.StatusNoContent)
case strings.HasSuffix(r.URL.Path, "/info"): case strings.HasSuffix(r.URL.Path, "/info"):
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`) _, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
default: default:
@@ -351,6 +372,19 @@ jobs:
require.Empty(t, redis.WorkingDir) require.Empty(t, redis.WorkingDir)
} }
// A whole-value `services:` expression only reaches the typed field through DecodeRaw.
func TestStartJobContainerGivesServicesTheirVolumesFromExpression(t *testing.T) {
redis := startJobContainerInputs(t, `
jobs:
job:
services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"]}}') }}
`, &Config{ValidVolumes: []string{"data"}})[0]
require.Equal(t, "redis:latest", redis.Image)
require.Equal(t, []string{"data"}, redis.ValidVolumes)
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
}
// Only the workflow's options may be stripped later, so the two sources have to reach the // Only the workflow's options may be stripped later, so the two sources have to reach the
// container apart from each other. // container apart from each other.
func TestStartJobContainerKeepsRunnerOptionsApartFromWorkflowOptions(t *testing.T) { func TestStartJobContainerKeepsRunnerOptionsApartFromWorkflowOptions(t *testing.T) {
@@ -607,6 +641,30 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
}) })
t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("container paths are linux paths")
}
rc := &RunContext{
Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}},
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}},
}
gotbind, _, err := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
rc.Config.BindWorkdir = false
_, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
require.NoError(t, rc.Run.Job().RawContainer.Encode(map[string][]string{"volumes": {"claimed:/workspace/1"}}))
_, gotmount, err = rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
})
} }
func TestRunContextValidVolumes(t *testing.T) { func TestRunContextValidVolumes(t *testing.T) {
@@ -635,6 +693,8 @@ func TestRunContextValidVolumes(t *testing.T) {
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir) assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
rc.Config.BindWorkdir = true rc.Config.BindWorkdir = true
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir) assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
rc.Config.PresetGitHubContext = &model.GithubContext{}
assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1"))
} }
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) { func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
@@ -647,39 +707,142 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
serviceContainers: []*serviceContainer{{name: "svc", container: service}}, serviceContainers: []*serviceContainer{{name: "svc", container: service}},
} }
err := rc.cleanupJobResources("external-network", false)(common.WithDryrun(t.Context(), true)) err := rc.cleanupJobResources("external-network", false, true)(common.WithDryrun(t.Context(), true))
require.NoError(t, err) require.NoError(t, err)
service.AssertExpectations(t) service.AssertExpectations(t)
} }
// cleanup used to bail out on a previous step's error and on a cancelled context func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) { now := time.Date(2026, time.September, 10, 12, 0, 0, 0, time.UTC)
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
removeError, closeError := errors.New("remove service"), errors.New("close service")
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 removeError }).Once()
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
rc := &RunContext{ rc := &RunContext{
Name: "job", Config: &Config{ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{RunnerUUID: "runner-1"}},
Config: &Config{}, Run: &model.Run{Workflow: &model.Workflow{Name: "workflow"}, JobID: "job"},
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"}, JobContainer: fakeContainer{},
JobContainer: jobContainer,
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
} }
volumes := map[string]volume.Volume{}
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
path := strings.TrimPrefix(request.URL.Path, "/v1.47")
switch {
case path == "/volumes/create":
options := volume.Volume{CreatedAt: now.Format(time.RFC3339)}
assert.NoError(t, json.UnmarshalRead(request.Body, &options))
volumes[options.Name] = options
assert.NoError(t, json.MarshalWrite(writer, volumes[options.Name]))
case path == "/volumes":
assert.JSONEq(t, `{"label":{"com.gitea.runner.uuid=runner-1":true},"dangling":{"true":true}}`, request.URL.Query().Get("filters"))
assert.NoError(t, json.MarshalWrite(writer, map[string]any{"Volumes": slices.Collect(maps.Values(volumes))}))
case request.Method == http.MethodDelete:
name := strings.TrimPrefix(path, "/volumes/")
assert.NotContains(t, []string{"1", "true"}, request.URL.Query().Get("force"))
if name == "became-active" || name == "remove-failed" {
writer.WriteHeader(http.StatusConflict)
_, _ = fmt.Fprintf(writer, `{"message":%q}`, name)
return
}
delete(volumes, name)
writer.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected Docker request: %s %s", request.Method, request.URL)
}
})
name := rc.jobContainerName()
require.NoError(t, rc.createJobVolumes(t.Context(), map[string]string{name: "/workspace", name + "-env": "/var/run/act", "shared-cache": "/cache"}))
assert.ElementsMatch(t, []string{name, name + "-env"}, slices.Collect(maps.Keys(volumes)))
rc.deferVolumeCleanup = func(common.Executor) {}
require.NoError(t, rc.cleanupJobResources("", false, false)(t.Context()))
require.Len(t, volumes, 2)
for _, name := range []string{"became-active", "remove-failed"} {
volumes[name] = volume.Volume{Name: name, Labels: volumes[rc.jobContainerName()].Labels, CreatedAt: now.Format(time.RFC3339)}
}
volumes["foreign"] = volume.Volume{Name: "foreign", Labels: map[string]string{"com.gitea.runner.uuid": "runner-2"}, CreatedAt: now.Format(time.RFC3339)}
volumes["fresh"] = volume.Volume{Name: "fresh", Labels: volumes[name].Labels, CreatedAt: now.Add(48 * time.Hour).Format(time.RFC3339)}
volumes["unknown-age"] = volume.Volume{Name: "unknown-age", Labels: volumes[name].Labels}
err := container.RemoveOrphanJobVolumes(t.Context(), "runner-1", now.Add(24*time.Hour))
require.ErrorContains(t, err, "became-active")
require.ErrorContains(t, err, "remove-failed")
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
}
ctx, cancel := context.WithCancel(context.Background()) func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
cancel() t.Setenv("TMPDIR", "/tmp")
err := rc.cleanupJobResources("job-network", true)(ctx) proxyDir := t.TempDir()
require.ErrorContains(t, err, "removal failed") for _, name := range []string{"synchronous", "proxy", "closed proxy", "deferred", "preclean deferred", "canceled"} {
require.ErrorIs(t, err, removeError) t.Run(name, func(t *testing.T) {
require.ErrorIs(t, err, closeError) proxy, preclean, deferred := strings.HasSuffix(name, "proxy"), strings.HasPrefix(name, "preclean"), strings.HasSuffix(name, "deferred")
require.ErrorIs(t, err, context.Canceled) if proxy && runtime.GOOS == "windows" {
jobContainer.AssertExpectations(t) t.Skip("Unix socket ownership is unavailable on Windows")
service.AssertExpectations(t) }
jobError, removeError, closeError := errors.New("remove job"), errors.New("remove service"), errors.New("close service")
job, service := &containerMock{}, &containerMock{}
job.On("Remove").Return(func(context.Context) error { return jobError }).Once()
service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
rc := &RunContext{
Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
JobContainer: job,
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
}
var volumeCleanup []common.Executor
if deferred {
rc.deferVolumeCleanup = func(cleanup common.Executor) { volumeCleanup = append(volumeCleanup, cleanup) }
}
volumeRemovals := 0
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodDelete {
volumeRemovals++
}
operation := request.Method + " " + strings.TrimPrefix(request.URL.Path, "/v1.47")
if request.URL.Query().Has("filters") {
operation = "labelled " + operation
}
writer.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(writer, `{"message":%q}`, operation)
})
if proxy {
listener, err := net.Listen("unix", filepath.Join(proxyDir, "d.sock"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, listener.Close()) })
rc.dockerProxy, err = container.StartDockerProxy(listener.Addr().String(), proxyDir, rc.jobContainerName())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, rc.closeDockerProxy(context.Background())) })
if name == "closed proxy" {
require.NoError(t, rc.closeDockerProxy(t.Context()))
}
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
if name == "canceled" {
cancel()
}
err := rc.cleanupJobResources("job-network", true, preclean)(ctx)
if deferred && !preclean {
require.Len(t, volumeCleanup, 1)
assert.Zero(t, volumeRemovals)
err = errors.Join(err, volumeCleanup[0](ctx))
} else {
assert.Empty(t, volumeCleanup)
}
for _, failure := range []error{jobError, removeError, closeError} {
require.ErrorIs(t, err, failure)
}
if name == "canceled" {
require.ErrorIs(t, err, context.Canceled)
} else {
for _, operation := range []string{"DELETE /volumes/" + rc.jobContainerName(), "DELETE /volumes/" + rc.jobContainerName() + "-env", "GET /networks"} {
require.ErrorContains(t, err, operation)
}
assert.Equal(t, 2, volumeRemovals)
}
assert.Equal(t, proxy || preclean, strings.Contains(err.Error(), "labelled GET /containers/json"))
if proxy || preclean {
require.ErrorContains(t, err, "labelled GET /networks")
require.ErrorContains(t, err, "labelled GET /volumes")
}
assert.Nil(t, rc.dockerProxy)
assert.Equal(t, proxy, rc.hadDockerProxy)
})
}
} }
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one // TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
@@ -703,6 +866,20 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
require.Equal(t, "b", job.Outputs["o"]) require.Equal(t, "b", job.Outputs["o"])
} }
// A whole-value `outputs:` expression only reaches the typed field through DecodeRaw.
func TestInterpolateOutputsFromExpression(t *testing.T) {
var rawOutputs yaml.Node
require.NoError(t, rawOutputs.Encode(`${{ fromJSON('{"o":"resolved"}') }}`))
job := &model.Job{RawOutputs: rawOutputs}
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), run, nil)
require.NoError(t, err)
require.NoError(t, rc.interpolateOutputs()(t.Context()))
require.Equal(t, "resolved", job.Outputs["o"])
}
func TestGetGitHubContext(t *testing.T) { func TestGetGitHubContext(t *testing.T) {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
@@ -1089,65 +1266,96 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
type ctxKey string type ctxKey string
const sentinel = ctxKey("sentinel") const sentinel = ctxKey("sentinel")
// the fresh context is cancelled via defer on return, so capture state inside the stub for name, canceled := range map[string]bool{"cancellation during cleanup": false, "already canceled": true} {
type capture struct { t.Run(name, func(t *testing.T) {
calls int ctx, cancel := context.WithCancel(context.WithValue(t.Context(), sentinel, "v"))
err error defer cancel()
sentinel any if canceled {
cancel context.CancelFunc cancel()
} }
newRC := func(c *capture) *RunContext { calls := 0
return &RunContext{ (&RunContext{cleanUpJobContainer: func(ctx context.Context) error {
JobName: "job", calls++
cleanUpJobContainer: func(ctx context.Context) error { cancel()
c.calls++
if c.cancel != nil {
c.cancel()
}
deadline, ok := ctx.Deadline() deadline, ok := ctx.Deadline()
require.True(t, ok) require.True(t, ok)
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second) assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
c.err = ctx.Err() require.NoError(t, ctx.Err())
c.sentinel = ctx.Value(sentinel) assert.Equal(t, "v", ctx.Value(sentinel))
return nil return nil
}, }}).cleanupFailedStart(ctx)
} assert.Equal(t, 1, calls)
})
} }
t.Run("detaches teardown from cancellation during cleanup", func(t *testing.T) { for _, testcase := range []struct {
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v")) name, health string
defer cancel() pullError error
c := capture{cancel: cancel} }{
{name: "healthy", health: container.HealthHealthy},
{name: "unhealthy", health: container.HealthUnhealthy},
{name: "pull failure", pullError: errors.New("pull failed")},
} {
t.Run(testcase.name, func(t *testing.T) {
var operations []string
record := func(operation string, failure error) func(context.Context) error {
return func(context.Context) error {
operations = append(operations, operation)
return failure
}
}
job, service := &containerMock{}, &containerMock{}
for name, instance := range map[string]*containerMock{"job": job, "service": service} {
instance.On("Pull", true).Return(record(name+".Pull", map[string]error{"job": testcase.pullError}[name]))
instance.On("Create", mock.Anything, mock.Anything).Return(record(name+".Create", nil))
instance.On("Start", false).Return(record(name+".Start", nil))
instance.On("Remove").Return(record(name+".Remove", nil))
instance.On("Close").Return(record(name+".Close", nil))
}
job.On("Copy", mock.Anything, mock.Anything).Return(record("job.Copy", nil))
job.On("Inspect", mock.Anything).Return(&container.Info{ID: "job-id"}, nil).
Run(func(mock.Arguments) { operations = append(operations, "job.Inspect") })
for _, health := range []string{container.HealthStarting, testcase.health} {
service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil).
Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once()
}
service.On("DumpLogs", mock.Anything).Return(nil).
Run(func(mock.Arguments) { operations = append(operations, "service.DumpLogs") })
origNewContainer := newContainer
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image]
}
t.Cleanup(func() { newContainer = origNewContainer })
workflow, err := model.ReadWorkflow(strings.NewReader("jobs: {job: {services: {postgres: {image: postgres:latest}}}}"))
require.NoError(t, err)
rc := &RunContext{
Config: &Config{ForcePull: true, ContainerNetworkMode: "host", Workdir: "/workspace"},
Run: &model.Run{JobID: "job", Workflow: workflow},
platformImage: "node:20",
}
ctx := common.WithDryrun(t.Context(), true)
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
want := "job.Remove service.Remove service.Close service.Pull job.Pull"
if testcase.pullError != nil {
require.ErrorIs(t, err, testcase.pullError)
} else {
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect"
if testcase.health == container.HealthUnhealthy {
require.ErrorContains(t, err, "the service 'postgres' is unhealthy")
want += " service.DumpLogs"
} else {
require.NoError(t, err)
}
}
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
})
}
newRC(&c).cleanupFailedStart(ctx) (&RunContext{}).cleanupFailedStart(t.Context())
assert.Equal(t, 1, c.calls)
require.NoError(t, c.err)
assert.Equal(t, "v", c.sentinel)
})
t.Run("falls back to a fresh context when the input is done", func(t *testing.T) {
var c capture
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
cancel()
newRC(&c).cleanupFailedStart(ctx)
assert.Equal(t, 1, c.calls)
require.NoError(t, c.err)
assert.Equal(t, "v", c.sentinel)
})
t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
})
} }
func TestWaitForServiceContainers(t *testing.T) { func TestWaitForServiceContainers(t *testing.T) {
origInterval := serviceReadyPollInterval
serviceReadyPollInterval = time.Millisecond
defer func() { serviceReadyPollInterval = origInterval }()
newRunContext := func(timeout time.Duration, services ...*serviceContainer) *RunContext { newRunContext := func(timeout time.Duration, services ...*serviceContainer) *RunContext {
return &RunContext{ return &RunContext{
Config: &Config{ServiceReadyTimeout: timeout}, Config: &Config{ServiceReadyTimeout: timeout},
@@ -1165,16 +1373,26 @@ func TestWaitForServiceContainers(t *testing.T) {
service.AssertExpectations(t) service.AssertExpectations(t)
}) })
t.Run("waits while a service is still starting", func(t *testing.T) { t.Run("polls at a fixed interval and logs starting only once", func(t *testing.T) {
service := &containerMock{} synctest.Test(t, func(t *testing.T) {
service.On("Inspect", mock.Anything). var pollTimes []time.Duration
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice() started := time.Now()
service.On("Inspect", mock.Anything). service := &containerMock{}
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once() service.On("Inspect", mock.Anything).
Run(func(_ mock.Arguments) { pollTimes = append(pollTimes, time.Since(started)) }).
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice()
service.On("Inspect", mock.Anything).
Run(func(_ mock.Arguments) { pollTimes = append(pollTimes, time.Since(started)) }).
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service}) var output bytes.Buffer
require.NoError(t, rc.waitForServiceContainers()(context.Background())) logger := log.New()
service.AssertExpectations(t) logger.SetOutput(&output)
require.NoError(t, newRunContext(0, &serviceContainer{name: "postgres", container: service}).waitForServiceContainers()(common.WithLogger(t.Context(), logger.WithFields(nil))))
assert.Equal(t, []time.Duration{0, time.Second, 2 * time.Second}, pollTimes)
assert.Equal(t, 1, strings.Count(output.String(), "postgres service is starting."))
assert.Contains(t, output.String(), "postgres service is healthy.")
})
}) })
t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) { t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) {
@@ -1331,7 +1549,7 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
job := &containerMock{} job := &containerMock{}
job.On("Inspect", mock.Anything).Return(&container.Info{ job.On("Inspect", mock.Anything).Return(&container.Info{
ID: "job-container-id", ID: "job-container-id",
Mounts: map[string]string{"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data"}, Mounts: map[string]string{"/workspace": "/var/lib/docker/volumes/job/_data"},
}, nil) }, nil)
rc := &RunContext{ rc := &RunContext{
Config: &Config{Workdir: "/workspace/owner/repo/"}, Config: &Config{Workdir: "/workspace/owner/repo/"},
@@ -1342,7 +1560,7 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
require.NoError(t, rc.captureJobContainerInfo()(context.Background())) require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
assert.Equal(t, "job-container-id", rc.jobContainerID) assert.Equal(t, "job-container-id", rc.jobContainerID)
assert.Equal(t, "/var/lib/docker/volumes/job/_data", rc.Env["GITEA_DOCKER_WORKSPACE"]) assert.Equal(t, "/var/lib/docker/volumes/job/_data/owner/repo", rc.Env["GITEA_DOCKER_WORKSPACE"])
rc.Config.Workdir = "/elsewhere" rc.Config.Workdir = "/elsewhere"
rc.Env = map[string]string{} rc.Env = map[string]string{}
+28 -25
View File
@@ -19,6 +19,7 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
docker_container "github.com/moby/moby/api/types/container" docker_container "github.com/moby/moby/api/types/container"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"go.yaml.in/yaml/v4"
) )
// Config contains the config for a new runner // Config contains the config for a new runner
@@ -155,6 +156,18 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) {
return runner, nil return runner, nil
} }
func maxParallelFor(strategy *model.Strategy, combinations int) int {
maxParallel := 4 // actionslib has no default for an undeclared max-parallel
if strategy != nil {
if limit, declared, err := strategy.ParseMaxParallel(); err != nil {
log.Errorf("Ignoring invalid max-parallel: %v", err)
} else if declared {
maxParallel = limit
}
}
return min(maxParallel, combinations)
}
// NewPlanExecutor ... // NewPlanExecutor ...
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
maxJobNameLen := 0 maxJobNameLen := 0
@@ -191,21 +204,24 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
log.Debugf("Job.With: %v", job.With) log.Debugf("Job.With: %v", job.With)
log.Debugf("Job.Result: %v", job.Result) log.Debugf("Job.Result: %v", job.Result)
if job.Strategy != nil { if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.FailFast)
log.Debugf("Job.Strategy.MaxParallel: %v", job.Strategy.MaxParallel)
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
strategyRc, err := runner.newRunContext(ctx, run, nil) strategyRc, err := runner.newRunContext(ctx, run, nil)
if err != nil { if err != nil {
return err return err
} }
// Resolve template expressions in the matrix node before Matrix() is called. if job.RawStrategy.Kind == yaml.ScalarNode {
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback. if err := decodeDeferred(ctx, strategyRc.ExprEval, "job strategy", job.RawStrategy, &job.Strategy); err != nil {
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil { return err
log.Errorf("Error while evaluating matrix: %v", err) }
} else {
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
// An unevaluated expression is left in place, which GetMatrixes below rejects.
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
log.Errorf("Error while evaluating matrix: %v", err)
}
} }
} }
@@ -215,20 +231,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
} }
log.Debugf("Job Matrices: %v", matrixes) log.Debugf("Job Matrices: %v", matrixes)
maxParallel := 4 maxParallel := maxParallelFor(job.Strategy, len(matrixes))
if job.Strategy != nil {
// Ensure GetMaxParallel() is called if MaxParallel is still 0
if job.Strategy.MaxParallel == 0 {
job.Strategy.MaxParallel = job.Strategy.GetMaxParallel()
}
maxParallel = job.Strategy.MaxParallel
log.Debugf("Using job.Strategy.MaxParallel: %d", maxParallel)
}
if len(matrixes) < maxParallel {
log.Debugf("Adjusting maxParallel from %d to %d (number of matrix combinations)", maxParallel, len(matrixes))
maxParallel = len(matrixes)
}
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes)) log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
+9
View File
@@ -19,6 +19,7 @@ import (
"gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"go.yaml.in/yaml/v4"
) )
type step interface { type step interface {
@@ -261,6 +262,14 @@ func setupEnv(ctx context.Context, step step) error {
} }
} }
} }
if step.getStepModel().RawWith.Kind == yaml.ScalarNode {
decoded := &model.Step{}
if err := decodeDeferred(ctx, inputEval(), "with", step.getStepModel().RawWith, &decoded.With); err != nil {
return err
}
step.getStepModel().With = decoded.With
mergeIntoMap(step, step.getEnv(), decoded.GetEnv())
}
return nil return nil
} }
+50
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"errors" "errors"
"os" "os"
"strings"
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -205,6 +206,55 @@ func TestSetupEnv(t *testing.T) {
}, env) }, env)
cm.AssertExpectations(t) cm.AssertExpectations(t)
for _, expression := range []string{"inputs.args", "env.ARGS"} {
t.Run("deferred inputs from "+expression, func(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
env: ${{ fromJSON(matrix.env) }}
jobs:
test:
env:
PRIORITY: job
steps:
- uses: ./act
env:
ARGS: ${{ inputs.args }}
with: ${{ fromJSON(` + expression + `) }}
`))
require.NoError(t, err)
job := workflow.GetJob("test")
rawEnv := model.CloneYamlNode(workflow.RawEnv)
rawWith := model.CloneYamlNode(job.Steps[0].RawWith)
for _, value := range []string{"first", "second"} {
t.Run(value, func(t *testing.T) {
t.Parallel()
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, map[string]any{"env": `{"WORKFLOW":"` + value + `","PRIORITY":"workflow"}`})
require.NoError(t, err)
rc.workflowCallInputs = map[string]any{"args": `{"name":"` + value + `","fetch-depth":2}`}
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
step := &stepRun{RunContext: rc, Step: job.Steps[0].Clone(), env: map[string]string{}}
require.NoError(t, setupEnv(t.Context(), step))
assert.Equal(t, map[string]string{"ARGS": `${{ inputs.args }}`, "INPUT_NAME": value, "INPUT_FETCH-DEPTH": "2"}, step.Step.GetEnv())
assert.Equal(t, value, step.env["INPUT_NAME"])
assert.Equal(t, value, step.env["WORKFLOW"])
assert.Equal(t, "job", step.env["PRIORITY"])
assert.Equal(t, rawEnv, workflow.RawEnv)
assert.Equal(t, rawWith, job.Steps[0].RawWith)
assert.Nil(t, workflow.Env)
assert.Nil(t, job.Steps[0].With)
})
}
})
}
t.Run("rejects inputs that still contain an expression", func(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n test:\n steps:\n - uses: ./act\n with: ${{ inputs.args }}\n"))
require.NoError(t, err)
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err)
rc.workflowCallInputs = map[string]any{"args": "${{ inputs.unresolved }}"}
require.ErrorContains(t, setupEnv(t.Context(), &stepRun{RunContext: rc, Step: workflow.Jobs["test"].Steps[0].Clone(), env: map[string]string{}}), "with:")
})
} }
func TestIsStepEnabled(t *testing.T) { func TestIsStepEnabled(t *testing.T) {
+48
View File
@@ -6,3 +6,51 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./action - uses: ./action
- if: env.PROXY_TEST_MODE == 'proxy'
run: |
docker run -d --restart always --network "$PROXY_TEST_RESOURCE" --name "$PROXY_TEST_RESOURCE-detached" "$PROXY_TEST_IMAGE" sleep infinity
echo "{services: {app: {image: $PROXY_TEST_IMAGE, command: sleep infinity, volumes: [named:/named]}}, volumes: {named: {}}}" | docker compose -f - up -d
binds:
needs: proxy
runs-on: ubuntu-latest
steps:
- run: |
test -z "$(docker ps -aq -f name="$PROXY_TEST_RESOURCE-detached" -f name="$PROXY_TEST_RESOURCE-app"; docker volume ls -q -f name="${PROXY_TEST_RESOURCE}_named"; docker network ls -q -f name="${PROXY_TEST_RESOURCE}_default")"
mkdir data && echo from-job > data/marker
- id: run
run: |
docker run --rm -v "${{ github.workspace }}:/src:ro" -v "$PWD:$PWD" -v ./data/created:/created -v "$GITHUB_OUTPUT:/output" \
--mount "type=bind,src=$PWD/data/../data,dst=/spelled,readonly" \
--mount "type=volume,dst=/device,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$PWD/data" \
"$PROXY_TEST_IMAGE" sh -c 'cat /src/data/marker "$0/data/marker" /spelled/marker /device/marker | grep -c from-job | grep -qx 4 && echo from-nested > /created/file && echo value=from-nested >> /output' "$PWD"
grep -qx from-nested data/created/file
- run: |
test "${{ steps.run.outputs.value }}" = from-nested
cat > compose.yaml <<'EOF'
services:
app:
image: ${PROXY_TEST_IMAGE}
command: cat /short/marker /long/marker /named/marker
volumes:
- ./data:/short
- type: bind
source: ./data
target: /long
bind:
create_host_path: false
- named:/named
volumes:
named:
driver: local
driver_opts:
type: none
o: bind
device: ./data
EOF
test "$(docker compose run --rm app | grep -c from-job)" = 3
docker compose down --volumes
- run: |
docker run --rm --user 1001 --group-add 2375 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/local/bin/docker:/usr/local/bin/docker:ro -v "${GITEA_DOCKER_WORKSPACE:?}/data:/data:ro" \
"$PROXY_TEST_IMAGE" sh -c 'grep -qx from-job /data/marker && docker volume create "$0"' "$PROXY_TEST_RESOURCE-nested"
test "$(docker volume inspect -f '{{ index .Labels "com.gitea.runner.job" }}' "$PROXY_TEST_RESOURCE-nested")" = "$JOB_CONTAINER_NAME"
echo "docker binds verified"
+1 -1
View File
@@ -360,6 +360,6 @@ func (f *GiteaFixture) Close(ctx context.Context) error {
if f.id == "" { if f.id == "" {
return f.cli.Close() return f.cli.Close()
} }
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true}) _, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
return errors.Join(removeErr, f.cli.Close()) return errors.Join(removeErr, f.cli.Close())
} }
+14 -23
View File
@@ -5,14 +5,14 @@ go 1.27
toolchain go1.27.1 toolchain go1.27.1
require ( require (
connectrpc.com/connect v1.20.0 connectrpc.com/connect v1.21.0
dario.cat/mergo v1.0.2 dario.cat/mergo v1.0.2
gitea.dev/actionslib v0.7.0 gitea.dev/actionslib v1.0.0
github.com/avast/retry-go/v5 v5.0.0 github.com/avast/retry-go/v5 v5.0.0
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.7.2+incompatible github.com/docker/cli v29.8.0+incompatible
github.com/docker/go-connections v0.8.1 github.com/docker/go-connections v0.8.1
github.com/docker/go-units v0.5.0 github.com/docker/go-units v0.5.0
github.com/go-git/go-billy/v5 v5.9.1 github.com/go-git/go-billy/v5 v5.9.1
@@ -24,8 +24,8 @@ require (
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.24 github.com/mattn/go-isatty v0.0.24
github.com/moby/go-archive v0.3.3 github.com/moby/go-archive v0.3.3
github.com/moby/moby/api v1.55.0 github.com/moby/moby/api v1.56.0
github.com/moby/moby/client v0.5.1 github.com/moby/moby/client v0.6.0
github.com/moby/patternmatcher v0.6.1 github.com/moby/patternmatcher v0.6.1
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.15.1 github.com/opencontainers/selinux v1.15.1
@@ -37,15 +37,15 @@ require (
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
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.6
golang.org/x/net v0.58.0 golang.org/x/net v0.59.0
golang.org/x/sync v0.22.0 golang.org/x/sync v0.23.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.48.0
golang.org/x/term v0.45.0 golang.org/x/term v0.46.0
golang.org/x/text v0.41.0 golang.org/x/text v0.42.0
google.golang.org/protobuf v1.36.12 google.golang.org/protobuf v1.36.12
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.1
) )
require ( require (
@@ -55,14 +55,12 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.5 // indirect github.com/cloudflare/circl v1.6.5 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/cyphar/filepath-securejoin v0.7.0 // indirect
github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/docker/docker-credential-helpers v0.9.8 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/logr v1.4.4 // indirect
@@ -75,9 +73,6 @@ require (
github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-runewidth v0.0.28 // indirect
github.com/mattn/go-shellwords v1.0.14 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/user v0.4.1 // indirect
@@ -87,21 +82,17 @@ require (
github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/rhysd/actionlint v1.7.12 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect github.com/skeema/knownhosts v1.3.2 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.56.0 // indirect golang.org/x/crypto v0.57.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )
+30 -48
View File
@@ -1,11 +1,11 @@
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik= cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik=
cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
gitea.dev/actionslib v0.7.0 h1:JCV8eeIGwjlXcuSr7ojEdQC22VoE2466+K+D9vuQWKQ= gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY=
gitea.dev/actionslib v0.7.0/go.mod h1:DI3Lqp+8TrycM7/semMdqsDQOHaBskjIAuMn+SXfZJ0= gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -25,8 +25,6 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -44,8 +42,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
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.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v29.8.0+incompatible h1:ih0c2jq/nN7QfES8zIfwSzIhRScjs4ehER+kZ60aeSk=
github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q=
github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
@@ -56,8 +56,6 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -112,22 +110,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-shellwords v1.0.14 h1:yUKzIgsCnosndOASY6/enly1EAuaXeFSQ7cdyA3OuYg=
github.com/mattn/go-shellwords v1.0.14/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
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.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00= github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
@@ -163,13 +155,11 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@@ -188,7 +178,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -199,13 +188,6 @@ github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 h1:zjNCuOOhh1T
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928/go.mod h1:PCFYfAEfKT+Nd6zWvUpsXduMR1bXFLf0uGSlEF05MCI= github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928/go.mod h1:PCFYfAEfKT+Nd6zWvUpsXduMR1bXFLf0uGSlEF05MCI=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
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.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
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=
@@ -231,18 +213,18 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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=
@@ -251,14 +233,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
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.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -277,5 +259,5 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY= tags.cncf.io/container-device-interface v1.1.1 h1:YPwQz4xg8PlQ0yT/baR0BtLpTQROe4l6M1yuRgAu1vc=
tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q= tags.cncf.io/container-device-interface v1.1.1/go.mod h1:S1PSJWYPD4Iom0/39mvr/VVFCfG0Yt14j20d0OYrY2M=
+60 -17
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -67,6 +68,8 @@ type Runner struct {
cacheHandler *artifactcache.Handler cacheHandler *artifactcache.Handler
capabilities string capabilities string
isolatedCacheNetwork func() string
runningTasks sync.Map runningTasks sync.Map
runningCount atomic.Int64 runningCount atomic.Int64
lastIdleCleanupUnixNano atomic.Int64 lastIdleCleanupUnixNano atomic.Int64
@@ -110,7 +113,6 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
} else { } else {
cacheHandler = handler cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/" envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
warnIfCacheUnreachable(cfg, handler.ExternalURL())
} }
} }
} }
@@ -135,6 +137,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
now: time.Now, now: time.Now,
runHealthCheck: executeHealthCheck, runHealthCheck: executeHealthCheck,
} }
runner.isolatedCacheNetwork = sync.OnceValue(runner.detectIsolatedCacheNetwork)
return runner return runner
} }
@@ -146,6 +149,8 @@ func (r *Runner) Close() error {
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon. // removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
var removeOrphanNetworks = container.RemoveOrphanNetworks var removeOrphanNetworks = container.RemoveOrphanNetworks
var removeOrphanJobVolumes = container.RemoveOrphanJobVolumes
// OnIdle performs lightweight maintenance during polling idle windows. // OnIdle performs lightweight maintenance during polling idle windows.
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup // It runs synchronously on the poller goroutine; shouldRunIdleCleanup
// throttles invocations to runner.idle_cleanup_interval so the impact on // throttles invocations to runner.idle_cleanup_interval so the impact on
@@ -167,21 +172,20 @@ func (r *Runner) OnIdle(ctx context.Context) {
if hostRoot := filepath.FromSlash(r.cfg.Host.WorkdirParent); hostRoot != "" { if hostRoot := filepath.FromSlash(r.cfg.Host.WorkdirParent); hostRoot != "" {
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir) r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
} }
r.cleanupOrphanNetworks(ctx) r.cleanupOrphanDockerResources(ctx)
} }
// cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
// tear down. A labelled network with no containers on it is finished with, and as for the if r.uuid == "" || (!r.requiresDocker() && !dockerReachable(ctx)) {
// directories above, a task beginning during the pass is safe because the cutoff keeps a
// network it has created but not yet attached a container to out of scope.
func (r *Runner) cleanupOrphanNetworks(ctx context.Context) {
if r.uuid == "" || !r.requiresDocker() {
return return
} }
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge) cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
if err := removeOrphanNetworks(ctx, r.uuid, cutoff); err != nil { if err := removeOrphanNetworks(ctx, r.uuid, cutoff); err != nil {
log.Warnf("failed to clean up networks left behind by earlier jobs: %v", err) log.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
} }
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
log.Warnf("failed to clean up volumes left behind by earlier jobs: %v", err)
}
} }
func (r *Runner) shouldRunIdleCleanup() bool { func (r *Runner) shouldRunIdleCleanup() bool {
@@ -287,6 +291,7 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
defer r.runningTasks.Delete(task.Id) defer r.runningTasks.Delete(task.Id)
r.runningCount.Add(1) r.runningCount.Add(1)
defer r.runningCount.Add(-1)
start := time.Now() start := time.Now()
@@ -294,15 +299,25 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
defer cancel() defer cancel()
// A proxy URL may carry credentials, and every job is given it; keep them out of the log. // A proxy URL may carry credentials, and every job is given it; keep them out of the log.
reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg, proxyPasswords()...) reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg, proxyPasswords()...)
var volumeCleanup []common.Executor
var volumeCleanupMu sync.Mutex
if r.cfg.Runner.PostTaskScript == "" {
ctx = runner.WithJobVolumeCleanup(ctx, func(cleanup common.Executor) {
volumeCleanupMu.Lock()
defer volumeCleanupMu.Unlock()
volumeCleanup = append(volumeCleanup, cleanup)
})
}
var runErr error var runErr error
defer func() { defer func() {
r.runningCount.Add(-1)
lastWords := "" lastWords := ""
if runErr != nil { if runErr != nil {
lastWords = runErr.Error() lastWords = runErr.Error()
} }
_ = reporter.Close(lastWords) _ = reporter.Close(lastWords)
if err := cleanupJobVolumes(ctx, volumeCleanup); err != nil {
log.Warnf("task %d volume cleanup after reporting: %v", task.Id, err)
}
metrics.JobDuration.Observe(time.Since(start).Seconds()) metrics.JobDuration.Observe(time.Since(start).Seconds())
metrics.JobsTotal.WithLabelValues(metrics.ResultToStatusLabel(reporter.Result())).Inc() metrics.JobsTotal.WithLabelValues(metrics.ResultToStatusLabel(reporter.Result())).Inc()
@@ -313,6 +328,16 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
return nil return nil
} }
func cleanupJobVolumes(ctx context.Context, cleanups []common.Executor) error {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
var errs []error
for _, cleanup := range cleanups {
errs = append(errs, cleanup(ctx))
}
return errors.Join(errs...)
}
func (r *Runner) cloneEnvs() map[string]string { func (r *Runner) cloneEnvs() map[string]string {
// Reserve space for the per-task keys injected by run(): // Reserve space for the per-task keys injected by run():
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN, // ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
@@ -455,7 +480,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// is that server's responsibility to authenticate requests. // is that server's responsibility to authenticate requests.
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter) revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
defer revokeCache() defer revokeCache()
r.setResultsService(envs, resultsURL)
eventJSON, err := json.Marshal(preset.Event) eventJSON, err := json.Marshal(preset.Event)
if err != nil { if err != nil {
@@ -496,6 +520,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return fallbackPlatform() return fallbackPlatform()
} }
if resultsURL != "" && r.cacheIsolatedFrom(job, platformPicker) {
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf("jobs cannot reach the cache server at %s on docker network %q, so caching fails and artifacts go to Gitea directly, set cache.host and cache.port to an address jobs reach, or container.network to %[2]q",
r.cacheHandler.ExternalURL(), r.isolatedCacheNetwork())))
resultsURL = ""
}
r.setResultsService(envs, resultsURL)
runnerConfig := &runner.Config{ runnerConfig := &runner.Config{
// On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>" // On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>"
// On Windows, Workdir will be like "\<parent_directory>\<owner>\<repo>" // On Windows, Workdir will be like "\<parent_directory>\<owner>\<repo>"
@@ -825,12 +856,24 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
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) 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)
} }
func warnIfCacheUnreachable(cfg *config.Config, cacheURL string) { func (r *Runner) cacheIsolatedFrom(job *model.Job, pickPlatform func([]string) string) bool {
if cfg.Cache.Host != "" || cfg.Container.Network != "" { jobContainer := job.Container()
return return (jobContainer != nil && jobContainer.Image != "" || pickPlatform(job.RunsOn()) != labels.SelfHostedPlatform) && r.isolatedCacheNetwork() != ""
}
func (r *Runner) detectIsolatedCacheNetwork() string {
if r.cacheHandler == nil {
return ""
} }
if _, err := os.Stat("/.dockerenv"); err != nil { addr, err := netip.ParseAddr(hostOf(r.cacheHandler.ExternalURL()))
return if err != nil {
return ""
} }
log.Warnf("jobs are given %s for the cache server; if they cannot reach it, set container.network to a network this runner is on, or cache.host", cacheURL) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
network, err := container.IsolatedNetwork(ctx, addr, r.cfg.Container.Network)
if err != nil {
log.Warnf("cannot check whether jobs reach the cache server: %v", err)
}
return network
} }
@@ -311,6 +311,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
} }
var swept []string var swept []string
var sweptVolumes []string
var sweptCutoff time.Time var sweptCutoff time.Time
origRemoveOrphanNetworks := removeOrphanNetworks origRemoveOrphanNetworks := removeOrphanNetworks
removeOrphanNetworks = func(_ context.Context, runnerUUID string, createdBefore time.Time) error { removeOrphanNetworks = func(_ context.Context, runnerUUID string, createdBefore time.Time) error {
@@ -319,15 +320,32 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
return nil return nil
} }
t.Cleanup(func() { removeOrphanNetworks = origRemoveOrphanNetworks }) t.Cleanup(func() { removeOrphanNetworks = origRemoveOrphanNetworks })
origRemoveOrphanJobVolumes := removeOrphanJobVolumes
removeOrphanJobVolumes = func(_ context.Context, runnerUUID string, createdBefore time.Time) error {
sweptVolumes = append(sweptVolumes, runnerUUID)
assert.Equal(t, now.Add(-24*time.Hour), createdBefore)
return nil
}
t.Cleanup(func() { removeOrphanJobVolumes = origRemoveOrphanJobVolumes })
r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }} r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }}
r.OnIdle(context.Background()) r.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1"}, swept) assert.Equal(t, []string{"runner-1"}, swept)
assert.Equal(t, swept, sweptVolumes)
// a network of a job starting during the pass is younger than this and so out of scope // a network of a job starting during the pass is younger than this and so out of scope
assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff) assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff)
// a host-only runner has no daemon to sweep // a host-only runner has no daemon to sweep
origDockerReachable := dockerReachable
dockerReachable = func(context.Context) bool { return false }
t.Cleanup(func() { dockerReachable = origDockerReachable })
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }} hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
hostOnly.OnIdle(context.Background()) hostOnly.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1"}, swept) assert.Equal(t, []string{"runner-1"}, swept)
assert.Equal(t, swept, sweptVolumes)
dockerReachable = func(context.Context) bool { return true }
now = now.Add(time.Minute)
hostOnly.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1", "runner-2"}, sweptVolumes)
} }
+124
View File
@@ -5,10 +5,18 @@ package run
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/runner" "gitea.com/gitea/runner/act/runner"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks" clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
@@ -16,6 +24,7 @@ import (
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect" "connectrpc.com/connect"
"gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1" runnerv1 "gitea.dev/actionslib/runner/v1"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -73,6 +82,113 @@ func TestRunnerRunningCountAndNullLogger(t *testing.T) {
require.NotNil(t, logger.Out) require.NotNil(t, logger.Out)
} }
func TestRunnerReclaimsVolumesAfterReporting(t *testing.T) {
for _, mode := range []string{"deferred", "post-task script"} {
t.Run(mode, func(t *testing.T) {
cfg := &config.Config{
Cache: config.Cache{Enabled: new(false)},
Runner: config.Runner{Timeout: time.Minute, LogReportInterval: time.Minute, StateReportInterval: time.Minute},
Container: config.Container{Network: "host", DockerHost: "-"},
}
if mode == "post-task script" {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX script")
}
cfg.Runner.PostTaskScript = filepath.Join(t.TempDir(), "post-task.sh")
require.NoError(t, os.WriteFile(cfg.Runner.PostTaskScript, []byte("#!/bin/sh\n: > \"$0.done\"\n"), 0o700))
}
cli := clientmocks.NewClient(t)
cli.AddressValue = "https://gitea.example/"
r := NewRunner(cfg, &config.Registration{UUID: "runner-1", Labels: []string{"ubuntu:docker://node:20"}}, cli)
var reported atomic.Bool
var removed atomic.Int64
cli.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
assert.False(t, reported.Load())
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
})
cli.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, req.Msg.State.Result)
assert.Equal(t, int64(1), r.RunningCount())
if mode == "post-task script" {
assert.FileExists(t, cfg.Runner.PostTaskScript+".done")
}
reported.Store(true)
}
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: req.Msg.State}), nil
})
var volumesCreated atomic.Bool
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("API-Version", "1.47")
path := strings.TrimPrefix(request.URL.Path, "/v1.47")
if response, ok := map[string]string{
"/_ping": "OK",
"/info": `{"Architecture":"amd64","OSType":"linux"}`,
"/containers/json": "[]",
"/networks": "[]",
"/volumes": `{"Volumes":[]}`,
"/containers/create": `{"Id":"job-id"}`,
"/containers/job-id/json": `{"Id":"job-id","Config":{},"State":{"Status":"running"}}`,
}[path]; ok {
_, _ = io.WriteString(writer, response)
return
}
switch {
case strings.HasPrefix(path, "/images/"):
_, _ = io.WriteString(writer, `{"Id":"image-id","Config":{},"Os":"linux","Architecture":"amd64"}`)
case path == "/volumes/create":
volumesCreated.Store(true)
_, _ = io.WriteString(writer, "{}")
case strings.HasSuffix(path, "/exec"):
_, _ = io.WriteString(writer, `{"Id":"exec-id"}`)
case strings.HasPrefix(path, "/exec/"):
_, _ = io.WriteString(writer, `{"Running":false,"ExitCode":0}`)
case request.Method == http.MethodDelete || strings.HasSuffix(path, "/start") || strings.HasSuffix(path, "/kill") || strings.HasSuffix(path, "/archive"):
if request.Method == http.MethodDelete && strings.HasPrefix(path, "/volumes/") && volumesCreated.Load() {
assert.Equal(t, mode != "post-task script", reported.Load())
if mode == "post-task script" {
assert.NoFileExists(t, cfg.Runner.PostTaskScript+".done")
}
assert.Equal(t, int64(1), r.RunningCount())
removed.Add(1)
}
writer.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected Docker request: %s %s", request.Method, request.URL)
http.NotFound(writer, request)
}
}))
t.Cleanup(daemon.Close)
t.Setenv("DOCKER_HOST", daemon.URL)
require.NoError(t, r.Run(t.Context(), &runnerv1.Task{
Context: &structpb.Struct{},
WorkflowPayload: []byte("jobs:\n job:\n runs-on: ubuntu\n steps:\n - run: exit 0\n if: false\n"),
}))
assert.True(t, reported.Load())
assert.Equal(t, int64(2), removed.Load())
assert.Zero(t, r.RunningCount())
})
}
}
func TestCleanupJobVolumesJoinsErrorsAfterCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
err := cleanupJobVolumes(ctx, []common.Executor{
func(ctx context.Context) error {
require.NoError(t, ctx.Err())
deadline, ok := ctx.Deadline()
assert.True(t, ok)
assert.InDelta(t, time.Minute.Seconds(), time.Until(deadline).Seconds(), 1)
return io.EOF
},
func(context.Context) error { return io.ErrClosedPipe },
})
require.ErrorIs(t, err, io.EOF)
require.ErrorIs(t, err, io.ErrClosedPipe)
}
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) { func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
cacheEnabled := false cacheEnabled := false
cfg := &config.Config{} cfg := &config.Config{}
@@ -222,6 +338,14 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"], instance) assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"], instance)
assert.Empty(t, envs[runner.CacheServiceV2Env]) assert.Empty(t, envs[runner.CacheServiceV2Env])
} }
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
require.NoError(t, err)
r.isolatedCacheNetwork = func() string { return "compose" }
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
assert.Equal(t, isolated, r.cacheIsolatedFrom(workflow.GetJob(job), pickPlatform), job)
}
} }
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured // The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
+11 -8
View File
@@ -53,11 +53,12 @@ runner:
# scratch directories (left behind when a host cleanup delete stalls) older than # scratch directories (left behind when a host cleanup delete stalls) older than
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0 # this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
# (or any non-positive value) disables stale-directory cleanup entirely, along with # (or any non-positive value) disables stale-directory cleanup entirely, along with
# the docker network cleanup below. # the docker network and volume cleanup below.
#workdir_cleanup_age: 24h #workdir_cleanup_age: 24h
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use # Cadence for the idle cleanup pass. Besides the directories above, on runners that use
# docker it removes the per-job networks of jobs this runner did not live to tear down, # docker it removes the per-job networks and volumes of jobs this runner did not live to
# which would otherwise hold a subnet of the daemon address pool until the host is rebuilt. # tear down, which would otherwise hold a subnet of the daemon address pool and the job's
# workspace contents until the host is rebuilt.
#idle_cleanup_interval: 10m #idle_cleanup_interval: 10m
# The base interval for periodic log flush to the Gitea instance. # The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size # Logs may be sent earlier if the buffer reaches log_report_batch_size
@@ -176,10 +177,11 @@ cache:
# 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.
#offline_mode: false #offline_mode: false
# Serve the actions cache service v2 API. The actions that use it refuse any host they do not # Serve the actions cache service v2 API. The actions that use it fall back to v1 on any host
# take for GitHub, so reaching it means editing that check out of their own bundle, put back # they do not take for GitHub, so reaching it means editing that check out of their own bundle,
# after the copy into the job. That edit is made either way, this only governs the API # put back after the copy into the job. That edit is made either way, this only governs the API
# advertised. A bundle that does not match is left alone. With v2, uploads need a reachable cache. # advertised. A bundle that does not match is left alone. With v2, artifact uploads go via the
# cache server unless container jobs cannot reach its Docker network.
#v2: true #v2: true
# How the cache server discards entries, ignored when external_server is set since that # How the cache server discards entries, ignored when external_server is set since that
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three # server applies its own. Leave a setting out for its default; 0s or 0 turns the three
@@ -259,7 +261,8 @@ container:
#docker_timeout: 0s #docker_timeout: 0s
# Mount the workspace from a host directory instead of a Docker volume, so jobs # Mount the workspace from a host directory instead of a Docker volume, so jobs
# can bind-mount it by its own path into sibling containers (".:/app" in docker # can bind-mount it by its own path into sibling containers (".:/app" in docker
# compose). Not needed when workflows use the GITEA_DOCKER_WORKSPACE variable. # compose). Not needed when jobs get the Docker proxy or workflows use the
# GITEA_DOCKER_WORKSPACE variable.
# The workspace parent directory must be mounted into the runner container. # The workspace parent directory must be mounted into the runner container.
#bind_workdir: false #bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become # How long a job waits for a service container that declares a healthcheck to become
+6 -6
View File
@@ -63,8 +63,8 @@ type Runner struct {
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources. FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources. FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle. FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories and orphaned host-mode scratch dirs older than this duration during idle cleanup. WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories, orphaned host-mode scratch dirs and orphaned docker job resources older than this duration during idle cleanup.
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories and orphaned docker networks) periodically while the runner is idle. Set to 0 to disable cleanup cadence. IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories and orphaned docker networks and volumes) periodically while the runner is idle. Set to 0 to disable cleanup cadence.
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush. LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent. LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size. LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
@@ -391,10 +391,10 @@ func warnUnknownKeys(file string, content []byte) {
decoder := yaml.NewDecoder(bytes.NewReader(content)) decoder := yaml.NewDecoder(bytes.NewReader(content))
decoder.KnownFields(true) decoder.KnownFields(true)
var typeErr *yaml.TypeError var loadErrs *yaml.LoadErrors
if err := decoder.Decode(&Config{}); errors.As(err, &typeErr) { if err := decoder.Decode(&Config{}); errors.As(err, &loadErrs) {
for _, message := range typeErr.Errors { for _, loadErr := range loadErrs.Errors {
log.Warnf("config file %q: %s, it will be ignored", file, message) log.Warnf("config file %q: %s, it will be ignored", file, loadErr)
} }
} }
} }
+12
View File
@@ -312,7 +312,19 @@ func allScalars(nodes []*yaml.Node) bool {
return true return true
} }
// quoteLeadingNewlines keeps a scalar starting with a newline out of block style, whose indentation indicator the encoder drops.
// TODO: remove once https://github.com/yaml/go-yaml/pull/396 is released.
func quoteLeadingNewlines(node *yaml.Node) {
if node.Kind == yaml.ScalarNode && strings.HasPrefix(node.Value, "\n") {
node.Style = yaml.DoubleQuotedStyle
}
for _, child := range node.Content {
quoteLeadingNewlines(child)
}
}
func encodeYAML(node *yaml.Node) ([]byte, error) { func encodeYAML(node *yaml.Node) ([]byte, error) {
quoteLeadingNewlines(node)
var buf bytes.Buffer var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf) encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2) encoder.SetIndent(2)
+7
View File
@@ -74,6 +74,13 @@ func TestEditValues(t *testing.T) {
assert.Equal(t, map[string]string{"EXISTING": "value", "ADDED": "yes"}, cfg.Runner.Envs) assert.Equal(t, map[string]string{"EXISTING": "value", "ADDED": "yes"}, cfg.Runner.Envs)
}, },
}, },
{
name: "set map entry starting with a newline before an indented line",
edit: func(file string) error { return SetValue(file, "runner.envs.ADDED", "\n indented\nplain\n") },
assert: func(t *testing.T, cfg *Config, _ string) {
assert.Equal(t, "\n indented\nplain\n", cfg.Runner.Envs["ADDED"])
},
},
{ {
name: "set replaces a list", name: "set replaces a list",
edit: func(file string) error { return SetValue(file, "runner.labels", "one", "two") }, edit: func(file string) error { return SetValue(file, "runner.labels", "one", "two") },
-6
View File
@@ -3,10 +3,4 @@
"extends": [ "extends": [
"local>gitea/renovate-config" "local>gitea/renovate-config"
], ],
"packageRules": [
{
"matchPackageNames": ["go.yaml.in/yaml/v4"],
"allowedVersions": "<4.0.0-rc.4", // rc.4 removes the error types actionlint builds against
},
],
} }
+5 -2
View File
@@ -125,14 +125,14 @@ if [ "$default_tests" = true ]; then
tar -C "$test_dir" -cf - runner.test -C "$PWD/act/runner" testdata/docker-proxy | \ tar -C "$test_dir" -cf - runner.test -C "$PWD/act/runner" testdata/docker-proxy | \
docker -H "$host_docker" exec -i "$name" tar -x -C /tmp/gitea-runner-proxy-test docker -H "$host_docker" exec -i "$name" tar -x -C /tmp/gitea-runner-proxy-test
socket="unix:///var/run/docker.sock" socket="unix:///var/run/docker.sock"
users=(0) users=(0 1000:2375)
if [ "$target" = dind-rootless ]; then if [ "$target" = dind-rootless ]; then
socket="unix:///run/user/1000/docker.sock" socket="unix:///run/user/1000/docker.sock"
users=(1000 0) users=(1000 0)
fi fi
for user in "${users[@]}"; do for user in "${users[@]}"; do
proxy_mode="proxy" proxy_mode="proxy"
if [ "$user" != 0 ]; then if [ "$user" = 1000:2375 ]; then
proxy_mode="direct" proxy_mode="direct"
fi fi
echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access" echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access"
@@ -140,4 +140,7 @@ if [ "$default_tests" = true ]; then
-e DOCKER_HOST="$socket" -e ACT_TEST_DOCKER_PROXY="$proxy_mode" -e ACT_TEST_IMAGE="$job_image" \ -e DOCKER_HOST="$socket" -e ACT_TEST_DOCKER_PROXY="$proxy_mode" -e ACT_TEST_IMAGE="$job_image" \
"$name" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m "$name" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
done done
echo "==> Running mounted Docker job in a container given the ${target} socket, expecting proxy access"
docker -H "$host_docker" exec -e DOCKER_HOST="$socket" "$name" docker run --rm -v "${socket#unix://}:/var/run/docker.sock" -v /tmp/gitea-runner-proxy-test:/data -w /data \
-e ACT_TEST_DOCKER_PROXY=proxy -e ACT_TEST_IMAGE="$job_image" "$job_image" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
fi fi